diff --git a/.claude/skills/knowledge-lookup/SKILL.md b/.claude/skills/knowledge-lookup/SKILL.md
index e1f426c..59d5c91 100644
--- a/.claude/skills/knowledge-lookup/SKILL.md
+++ b/.claude/skills/knowledge-lookup/SKILL.md
@@ -203,7 +203,7 @@ gives no hint. Never take the entry count as the prefix's ID count.
| Pipeline composition and execution context | `--topic pipeline,execution-context,cancellation-and-timeouts --section rules --brief` and `--prefix CTX,RECOV,PIPE --section rules --brief` | `--prefix-info PIPE`, `--gaps CTX,RECOV,PIPE` | live |
| Observability, configuration and redaction | `--topic observability,configuration,redaction-and-security --section rules --brief` and `--prefix CFG,OBS --section rules --brief` | `--prefix-info CFG`, `--prefix-info OBS`, `--gaps CFG,OBS` | live |
| Resilience: retry, redirect and authentication | `--topic retry-and-resilience,redirect-handling,authentication,cancellation-and-timeouts --section rules --brief` and `--prefix RETRY,REDIR,AUTH --section rules --brief` | `--prefix-info RETRY`, `--gaps RETRY,REDIR,AUTH,RECOV` | live |
-| Serialization, SSE and pagination | `--topic serde,sse-streaming,pagination --section rules --brief` and `--prefix SERDE,SSE,PAGE --section rules --brief` | `--prefix-info SERDE`, `--gaps SERDE,SSE,PAGE` | live |
+| Serialization, SSE and pagination | `--topic serde,sse-streaming,pagination --section rules,constraints,conclusions --brief` and `--prefix SERDE,SSE,PAGE --section rules,constraints,conclusions --brief` (a `--section rules` reading alone misses `SERDE-17`, `SERDE-24`, `SERDE-25` and `SERDE-30`, filed under Constraints and Conclusions; phase 7a's finding) | `--prefix-info SERDE`, `--gaps SERDE,SSE,PAGE` | live |
| Transport and async-runtime adapters | `--topic transport-adapter,cancellation-and-timeouts,concurrency-and-async --section rules --brief` and `--prefix TRANSPORT,ASYNC --section rules --brief` | `--prefix-info TRANSPORT`, `--gaps TRANSPORT,ASYNC` | live |
| Styleguide-vs-design conflicts | `--section conflicts --brief` | resolve via a note under notes/ | live |
diff --git a/CLAUDE.md b/CLAUDE.md
index e0e25e9..4e1b936 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,11 +13,13 @@ three-state PATCH — solved exactly once, and it deliberately does not compete
Work here is **spec-driven, not feature-driven**. `docs/product-spec/` is normative: 645 numbered requirements
across 19 prefixes. Before implementing anything, find the requirement IDs it must satisfy.
-**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b and 7c are built — the whole of phase 6 and
-two of phase 7's three sub-phases; the domain model, the seam layer, the byte-streaming layer, the body layer,
-the execution context, the recovery layer, the stage pipeline, the configuration layer, the tracing and
-metrics layer, the logging facade with its redaction, the retry layer, the authentication layer, the redirect
-layer, the server-sent-events layer and the pagination layer are the only domain code.** Six gems exist under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`,
+**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c and 7a are built — the whole of phase 6
+and the whole of phase 7, whose three sub-phases were built concurrently off one base and landed in that
+order, 7a last (umbrella #25 closes by hand); the domain model, the seam layer, the byte-streaming layer, the
+body layer, the execution context, the recovery layer, the stage pipeline, the configuration layer, the
+tracing and metrics layer, the logging facade with its redaction, the retry layer, the authentication layer,
+the redirect layer, the server-sent-events layer, the pagination layer and the serialization layer are the
+only domain code.** Six gems exist under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`,
`Response`, `Headers`, `Status`, `Method`, `Protocol`, `MediaType`, `Query`, `RequestOptions`, `HeaderName`, the
`HeaderSyntax`, `PercentEncoding` and `URL` function modules, and the construction contract `Dexpace::Model` /
`Dexpace::Builder` under one error root, `Dexpace::Error`
@@ -184,7 +186,24 @@ frozen engines `Paginator` (`#items`, `#pages`, `#each_item`, `#each_page`) and
fetcher front-end `Fetchers`; the state error `PageStateError`; the three RBS interfaces `_Strategy`,
`_Extractor` and `_Executor` in `page.rbs`; and one widening of phase 1, `Dexpace::URL.resolve`, the
RFC 3986 reference resolution beside `.parse!`
-(`docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md`);
+(`docs/work/mvp/phase7/phase7c/2026-09-10-phase7c-pagination-checklist.md`) — and the
+serialization layer, chapter 14, under `Dexpace::Serde` beside phase 2's seam: the witness protocol design
+§10.14 substituted for the reference's reflective type token — `Serde.witness!` / `.witness?` over
+`WITNESS_METHOD` / `DUMP_METHOD`, the frozen `DecodeContext` with its RFC 6901 `#pointer`, its eight `!`
+methods and its one raise site `#error!`, the three container combinators `List`, `Map` and `Nullable`
+with `.of` over the private scalar table `Scalars` and the named witness `BOOLEAN`, and the ISO-8601
+witness `Instant` (`P7-8`'s microsecond domain); the three-state PATCH type `Tristate` with `ABSENT`,
+`NULL`, `Present` and its private `Combinator` behind `Tristate.of` (`#dexpace_load_field` for the
+in-object case); the encode walk `Native.of` and the `OMIT` sentinel that make `SERDE-15`/`19`/`20`
+structural (`P7-9`); the two `_ResponseHandler`s phase 3b's `TypedResponse` was built to take,
+`DecodingHandler` and `StatusAwareHandler` with its `factory:`; the ninth body factory
+`Body.serialized(value, serde:)`; and `interface _Codec` settled in place (`#media_type` a `MediaType` or
+a `String`, `#load` over `_Witness`) — plus the workspace's second real gem, **`dexpace-serde-json`**:
+`Dexpace::Serde::JSON::Codec` over one private `::JSON::Coder` per instance (`P7-4`), `.default` a fresh
+instance per call, `.build` over a five-key option allowlist, `MINIMUM_JSON_VERSION` asserted at require
+time (`P7-7`), `REQUIRED_CORE` on the registration, and the `json >= 2.19.9` line in its gemspec — the
+first `NFR-2` third-party half spent, and the first gate run against it
+(`docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md`);
every other gem's `lib/` still holds its namespace module and a `VERSION` constant and nothing else. Nothing talks to a
socket yet. The workspace root
carries the `Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and
@@ -952,6 +971,57 @@ Each is one line plus the chapter to read before touching the area.
settlement overflows at ~2,600 pages through the real `Completer`; with an `executor:` the FIRST dispatch
is posted too, so a queued executor fetches nothing until it runs (`PAGE-29`, `PAGE-31`; 7c's P7-109,
P7-112). `Future#value(deadline:)` is the bounded wait a test uses where "hangs" is the failure mode.
+- **A witness answers `.dexpace_load(parsed, ctx)` and NOTHING ELSE makes one — `Serde.witness?` is a
+ `respond_to?` on that one name, never on `#call`** — a `#call` fallback would make every lambda a witness
+ and `SERDE-5`'s explicit witness and `SERDE-8`'s fail-fast construction both unenforceable; phase 2's
+ `FakeCodec#load` drives its witness through `#call` because it predates the protocol, so a core handler
+ test uses a named class answering BOTH. `ctx` is `Serde::DecodeContext`, never phase 4a's `Context`; the
+ root frame names the decode's TARGET (`expected Pet (Hash) at /, got NilClass`) and `Codec#load` builds it
+ with `DecodeContext.root(target: witness)` and screens nothing for nil, because `Tristate.of` and
+ `Nullable.of` legitimately want a top-level null (`SERDE-13`, `SERDE-20`; 7a's design, `R2`).
+- **The tri-state omission is core's `Native` walk, not each model's `#dexpace_dump`** — `Absent` dumps to
+ the `OMIT` sentinel, a Hash entry that walks to `OMIT` is dropped, an Array element or a top-level value
+ that does is `nil`, and anything non-native raises `SerializationError` naming the class, because
+ `::JSON.generate(Object.new)` returns the object's `#inspect` as a JSON string rather than raising
+ (`SERDE-15`, `SERDE-19`, `SERDE-20`; 7a's P7-9). `Present` validates in `#initialize` with `.new` AND `.[]`
+ private, so `Present[value: nil]` is not a fourth state either, and `Model#with` keeps it closed on 3.2.
+- **`Codec#load` reads UTF-8 unconditionally, validates it, and rescues `::JSON::JSONError` around the
+ parse ALONE** — 3a's `#read_utf8` retags without validating and `::JSON.parse` accepts invalid UTF-8,
+ returning a String whose `#valid_encoding?` is false (7a's P7-6); a `Dexpace::StreamError` is an
+ `::IOError` and structurally outside `JSONError`'s ancestry, so `SERDE-12` holds without a discipline —
+ and a `rescue StandardError` anywhere on that path is a guard the suite runs red. The whole text IS
+ materialised under `Dexpace::IO.max_materialized_bytes` (`SERDE-27`'s clause is deviated, 7a's P7-1); a
+ body above the ceiling is a `StreamError`, unwrapped, and the handler screens an empty body with
+ `BufferedSource#eof?` — never `#content_length` (`-1` when unknown) and never a parser message, which
+ differs between json 2.19.9 and 3.0. Both halves of that error's message are pinned — the target AND
+ `no body` — because a witness's own shape failure over the drained `""` names the target too, so a
+ target-only assertion passes with the screen gone (review round 1); an anonymous witness reads
+ `an anonymous witness`, never an empty name.
+- **`JSON::Coder` is constructed with keywords only, `strict: true` and `allow_duplicate_key: false` fixed by
+ the codec, and `encoders:` NEVER forwarded** — json 2.19.9 takes a positional options Hash and SWALLOWS an
+ unknown key, json 3.0 takes keywords and refuses one, a duplicate key is last-wins on 2.9, a warning on
+ 2.19.9 and a `ParserError` on 3.0, and `encoders:` is a keyword error on 3.0; the codec's own allowlist
+ is what makes a typo one `InvalidArgumentError` and a duplicate key one `DeserializationError` across the
+ range. The two fixed options are pinned at the KEYWORD level, never through the engine's behaviour —
+ json 3.0.2, the bundle's version on every row, refuses a duplicate key by default, so a codec that
+ dropped the option would stay green on every gate row and regress only at the 2.19.9 floor; a child
+ process prepends a recorder onto `::JSON::Coder`'s singleton class and reads what `.new` receives
+ (`codec_test.rb`'s `CoderKeywordsTest`). P7-7's require-time floor is likewise observable only OUTSIDE
+ the bundle — every gate row runs the bundle's json, above the floor — so `json/floor_test.rb` drives it
+ in a child process with `RUBYOPT` and the `BUNDLE_*`/`BUNDLER_*` keys cleared, pinning the
+ interpreter's stock json (2.6.3 / 2.7.2 / 2.9.1 / 2.18.0 across the matrix, every one below the floor)
+ with `gem` before the require; stock 4.0's 2.18.0 HAS a `JSON::Coder` and loads clean without the
+ assertion, which is the silently-unpatched case it exists for. rbs 4.2.0 declares no `JSON::Coder` and
+ json 3.0.2 ships no `sig/`, so the Steepfile's `:serde_json` target alone downgrades `Ruby::UnknownConstant` to
+ `:information` and the ivar is typed `untyped` (NFR-11 admits no `::JSON` type in the gem's `sig/`
+ either).
+- **An adapter's require-time registration breaks every "starts empty on a bare require" pin in ONE
+ `rake test:gems` process** — the runner loads every gem's suite together, so `Dexpace::Serde.resolve`
+ answers the JSON codec in core's own suite; the two seam-iterating pins and `serde_test.rb`'s "starts
+ empty" pin are asserted in a CHILD process that requires `dexpace` alone
+ (`instrumentation/independence_test.rb`'s `IO.popen` shape), and `Registry#swap` restores `resolved` but
+ never `factories`, so `serde_test.rb`'s two swap pins assert the override is GONE, never that nothing
+ resolves (five pins the code invalidated, converted on 7a's code branch).
## Public API surface
@@ -1054,30 +1124,34 @@ probe compares each against the live tree, and a count written anywhere else in
body layer, the phase-4a execution context, the phase-4b recovery layer, the phase-4c stage pipeline,
the phase-5a configuration layer, the phase-5b logging facade and redaction, the phase-5c tracing and
metrics layer, the phase-6a retry layer, the phase-6c authentication layer, the phase-6b redirect
- layer, the phase-7b server-sent-events layer and the phase-7c pagination layer — two hundred and
- eight phase-1, phase-2, phase-3a, phase-3b, phase-4a, phase-4b, phase-4c, phase-5a, phase-5b,
- phase-5c, phase-6a, phase-6b, phase-6c, phase-7b and phase-7c files under `lib/dexpace/` beside
- phase 0's `version.rb` (7b's nine are `sse.rb` and the eight under `sse/`; 7c's fifteen are `page.rb`
- and the fourteen under `page/`), every one mirrored in `sig/`, and every one of the two hundred and
- eight but the nineteen `private_constant`s `hooks.rb`, `context/call_key.rb`,
+ layer, the phase-7b server-sent-events layer, the phase-7c pagination layer and the phase-7a
+ serialization layer — two hundred and nineteen phase-1, phase-2, phase-3a, phase-3b, phase-4a,
+ phase-4b, phase-4c, phase-5a, phase-5b, phase-5c, phase-6a, phase-6b, phase-6c, phase-7b, phase-7c
+ and phase-7a files under `lib/dexpace/` beside phase 0's `version.rb` (7b's nine are `sse.rb` and the
+ eight under `sse/`; 7c's fifteen are `page.rb` and the fourteen under `page/`; 7a's eleven are all
+ under `serde/`, beside phase 2's three files there), every one mirrored in `sig/`, and every one of
+ the two hundred and nineteen but the nineteen `private_constant`s `hooks.rb`, `context/call_key.rb`,
`recovery/ownership.rb`, `pipeline/sync_driver.rb`, `pipeline/async_driver.rb`,
`configuration/parsers.rb`, `deep_value.rb`, `proxy/resolution.rb`, `instrumentation/render.rb`,
`instrumentation/emitter.rb`, `resilience/pacing_parsers.rb`, `resilience/retry_step_helpers.rb`,
`auth/validation.rb`, `redirect/origin.rb`, `redirect/location.rb`, `redirect/chain.rb`,
`redirect/emitter.rb`, `redirect/reissue.rb` and `page/closing.rb` mirrored
- in `test/`; every
- other
- gem is a phase-0 skeleton whose `lib/` holds the namespace module and a `VERSION` constant and nothing
- else. Every adapter gemspec declares `dexpace-core` and no third-party gem yet
- (design P0-9); the third-party half of each `NFR-2` budget arrives with the phase that writes the code
- needing it.
+ in `test/` (phase 7a's private constants all live inside public files and add none). `dexpace-serde-json`'s
+ `lib/` holds the phase-7a JSON codec — `dexpace/serde/json.rb` and `dexpace/serde/json/codec.rb` beside
+ phase 0's `version.rb`, both mirrored in `sig/`, the entry file mirrored in `test/` (with `floor_test.rb`
+ beside it) and the codec by its five suites there — and its gemspec declares `json >= 2.19.9`, the one
+ place that floor is stated.
+ Every other gem is a phase-0 skeleton whose `lib/` holds the namespace module and a `VERSION` constant
+ and nothing else, and its gemspec declares `dexpace-core` and no third-party gem yet (design P0-9); the
+ third-party half of each `NFR-2` budget arrives with the phase that writes the code needing it, as 7a's
+ did.
- There are eleven phase directories under `docs/work/*/`; `mvp/` is the only delivery, and it holds
the v1 roadmap, `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md`, plus `phase0/`,
`phase1/`, `phase2/`, `phase3/`, `phase4/`, `phase5/`, `phase6/`, `phase7/`, `phase8/`, `phase9/` and `phase10/`. `phase0/`, `phase1/` and `phase2/` each
carry that phase's design, plan and checklist; `phase3/` carries its segmentation design,
`docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`, and two sub-phase directories —
`phase3/phase3a/` and `phase3/phase3b/`, each holding that sub-phase's design, plan and checklist —
- sixteen checklists written so far, each at implementation; `phase4/`
+ seventeen checklists written so far, each at implementation; `phase4/`
carries its segmentation design,
`docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md`, and three sub-phase
directories — `phase4/phase4a/`, `phase4/phase4b/` and `phase4/phase4c/`; each holds that sub-phase's
@@ -1098,8 +1172,9 @@ probe compares each against the live tree, and a count written anywhere else in
`phase7/` carries its segmentation design,
`docs/work/mvp/phase7/2026-09-10-phase7-segmentation-design.md`, and three sub-phase
directories — `phase7/phase7a/` (serialization), `phase7/phase7b/` (server-sent events) and
- `phase7/phase7c/` (pagination); each holds a design and a plan, and `phase7b/` and `phase7c/` their
- checklists too, both written at implementation on 2026-09-20. Phase 7 is 107 IDs
+ `phase7/phase7c/` (pagination); each holds a design, a plan and a checklist, the three checklists all
+ written at implementation on 2026-09-20 — 7b's and 7c's landed first, 7a's last, reconciled onto the
+ tree that holds the other two. Phase 7 is 107 IDs
(`SERDE-1`–`30`, `SSE-1`–`41`, `PAGE-1`–`36`) and ships the workspace's second real gem,
`dexpace-serde-json`, inside `7a`. Its three sub-phases are independent — `SSE-37` makes `7b`'s
serde-independence a mechanised MUST, and §12's chapter intro states the same property for
@@ -1150,7 +1225,6 @@ probe compares each against the live tree, and a count written anywhere else in
and closes or narrows five `docs/first-release.md` lines while publishing nothing: every gem stays
at `0.0.0`.
Every checklist but phase 0's, phase 1's, phase 2's, phase 3a's, phase 3b's, phase 4a's, phase 4b's,
- phase 4c's, phase 5a's, phase 5b's, phase 5c's, phase 6a's, phase 6b's, phase 6c's, phase 7b's and
- phase 7c's is
- still to be written at execution time.
+ phase 4c's, phase 5a's, phase 5b's, phase 5c's, phase 6a's, phase 6b's, phase 6c's, phase 7b's,
+ phase 7c's and phase 7a's is still to be written at execution time.
- There are 40 harvested topics under `docs/knowledge/harvested/`; the harvest ran here on 2026-09-05.
diff --git a/README.md b/README.md
index efd6f53..05e1d66 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ not compete with `faraday` or `httpx` on the easiest way to fetch a JSON endpoin
## Status
-**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b and 7c are built.** Nothing is published. The repository holds six gems under
+**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c and 7a are built.** Nothing is published. The repository holds six gems under
`gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — the frozen,
validated wire types every later phase stands on (`docs/sdk-documentation/http.md`) — the seam
layer: the provider registry, the transport and codec seams, the core-owned async pivot,
@@ -75,17 +75,24 @@ pagination layer: the page value that owns one live response, the strategy contr
built-in strategies over a caller-supplied extractor and never a codec, the byte-for-byte query
splice, the eager-closing item view and the single-use, look-ahead page view over one private walk,
the blocking engine, the non-blocking engine driven through `Future#on_settle` as a re-arm
-trampoline, the fetcher front-end, and `URL.resolve` (`docs/sdk-documentation/pagination.md`); the
-operation-lifecycle and transport groups of the HTTP-tracer vocabulary are emitted by nothing yet,
-and nothing talks to a socket yet. The other five are still skeletons — a namespace, a `VERSION`, a gemspec, a
-signature mirror and a smoke suite:
+trampoline, the fetcher front-end, and `URL.resolve` (`docs/sdk-documentation/pagination.md`) — and the
+serialization layer: the witness protocol with its decode context, three container combinators, a named
+boolean witness and an ISO-8601 witness, the three-state PATCH type with its decode combinator, the
+native-form encode walk that makes tri-state PATCH structural, `Body.serialized`, and the two response
+handlers phase 3b's `TypedResponse` was built to take (`docs/sdk-documentation/serde.md`).
+`dexpace-serde-json` holds the JSON codec over one private `JSON::Coder` per instance, the
+`json >= 2.19.9` floor in its gemspec and asserted at require time, and the seam registration under
+`:json` — the first gem here with a third-party dependency, and the first the three zero-dependency
+gates have run against with one; the operation-lifecycle and transport groups of the HTTP-tracer
+vocabulary are emitted by nothing yet, and nothing talks to a socket yet. The other four are still
+skeletons — a namespace, a `VERSION`, a gemspec, a signature mirror and a smoke suite:
| Gem | Namespace | Runtime dependencies today |
|---|---|---|
| `dexpace-core` | `Dexpace` | none |
| `dexpace-transport-net_http` | `Dexpace::Transport::NetHTTP` | `dexpace-core` |
| `dexpace-transport-async_http` | `Dexpace::Transport::AsyncHTTP` | `dexpace-core` |
-| `dexpace-serde-json` | `Dexpace::Serde::JSON` | `dexpace-core` |
+| `dexpace-serde-json` | `Dexpace::Serde::JSON` | `dexpace-core`; `json >= 2.19.9` |
| `dexpace-async-thread` | `Dexpace::Async::Thread` | `dexpace-core` |
| `dexpace-conformance` | `Dexpace::Conformance` | `dexpace-core` |
diff --git a/Steepfile b/Steepfile
index 81f466f..7fd878e 100644
--- a/Steepfile
+++ b/Steepfile
@@ -39,7 +39,16 @@ target :serde_json do
check "gems/dexpace-serde-json/lib"
signature "gems/dexpace-serde-json/sig", "gems/dexpace-core/sig"
library "json"
- configure_code_diagnostics(D::Ruby.default)
+ # The one relaxation, on this target alone (phase 7a): rbs 4.2.0's stdlib json signatures
+ # declare JSONError, GeneratorError, ParserError, State, generate and parse -- and no
+ # `JSON::Coder`, the per-instance engine json 2.19.9 added and this adapter is built on -- while
+ # json 3.0.2 ships no sig/ of its own for `rbs collection` to pick up. So the codec's one
+ # `::JSON::Coder.new` is a Ruby::UnknownConstant that steep's default warning severity turns
+ # into a red gate. Downgraded to :information here, never a line-level ignore (no precedent in
+ # lib/) and never on core's strict target; the engine is typed `untyped` in the gem's sig
+ # (NFR-11 admits no `::JSON` type there either). Re-tighten to D::Ruby.default at the first rbs
+ # release that declares JSON::Coder.
+ configure_code_diagnostics(D::Ruby.default.merge({ D::Ruby::UnknownConstant => :information }))
end
target :async_thread do
diff --git a/docs/README.md b/docs/README.md
index 6b99fde..c535400 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -105,9 +105,10 @@ domain model, phase 2's seam layer, phase 3a's byte-streaming layer, phase 3b's
4a's execution context, phase 4b's recovery layer, phase 4c's stage pipeline, phase 5a's
configuration layer, phase 5b's logging facade and redaction, phase 5c's tracing and metrics
layer, phase 6a's retry layer, phase 6c's authentication layer, phase 6b's redirect layer, phase
-7b's server-sent-events layer and phase 7c's pagination layer; the other five are
+7b's server-sent-events layer, phase 7c's pagination layer and phase 7a's serialization layer;
+`dexpace-serde-json` holds phase 7a's JSON codec and declares `json >= 2.19.9`; the other four are
phase 0's skeletons, a namespace and a `VERSION`. The as-built documentation lands in
-[`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the sixteen pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the
+[`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the seventeen pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the
gate set is the thing phase 0 built, [`sdk-documentation/http.md`](./sdk-documentation/http.md),
because the domain model is the thing phase 1 built,
[`sdk-documentation/seams.md`](./sdk-documentation/seams.md), because the seam layer is the thing
@@ -133,10 +134,12 @@ the thing phase 6c built,
[`sdk-documentation/redirect.md`](./sdk-documentation/redirect.md), because the redirect layer and
the two `standard` constructors are the things phase 6b built,
[`sdk-documentation/sse.md`](./sdk-documentation/sse.md), because the server-sent-events layer and
-the serde-boundary gate are the things phase 7b built, and
+the serde-boundary gate are the things phase 7b built,
[`sdk-documentation/pagination.md`](./sdk-documentation/pagination.md), because the pagination
layer — the page value, the strategies, the two views, the two engines and the fetcher front-end —
-is the thing phase 7c built.
+is the thing phase 7c built, and
+[`sdk-documentation/serde.md`](./sdk-documentation/serde.md), because the serialization layer and the
+JSON codec are the things phase 7a built.
## Keeping this file true
diff --git a/docs/first-release.md b/docs/first-release.md
index ae55423..aed96c2 100644
--- a/docs/first-release.md
+++ b/docs/first-release.md
@@ -300,12 +300,17 @@ find it in a design document.
from the other side. **Two things owed before release**, which is why this is an entry and not only a
ledger row: the documented behaviour of a typed response handler on a body above
`MAX_MATERIALIZED_BYTES` must be stated in `docs/sdk-documentation/`, so a caller streaming a large
- JSON response meets a documented limit rather than an `::IOError`; and phase 8's adapters must each
- be checked for whether their library offers a pull parser that would satisfy the clause — phase 8's
- segmentation design already records that none of its three does. **Where it is carried.** `7a`'s
- checklist marks `SERDE-27` with the clause named and cites this entry; the deviation row is
- `7a P7-1`, consolidated into design §10 and audited by `docs/deviations.md`. Cites `SERDE-27`,
- `SEAM-21`, `IO-9`, `BODY-32`.
+ JSON response meets a documented limit rather than an `::IOError` — **done 2026-09-20**, when phase 7a
+ was built: `docs/sdk-documentation/serde.md` states it under "`#load` materialises the whole text",
+ and the codec's suite asserts the `StreamError` propagates unwrapped
+ (`gems/dexpace-serde-json/test/dexpace/serde/json/codec_load_test.rb`, the `R1/P7-1` case); and phase
+ 8's adapters must each be checked for whether their library offers a pull parser that would satisfy
+ the clause — phase 8's segmentation design already records that none of its three does, and this half
+ stays open until phase 8 lands. **Where it is carried.** `7a`'s checklist marks `SERDE-27` with the
+ clause named and cites this entry; the deviation row is `7a P7-1`, consolidated into design §10 and
+ audited by `docs/deviations.md`; as built, the codec drains through 3a's `#read_utf8` and the ceiling
+ it reads is the configured `Dexpace::IO.max_materialized_bytes`. Cites `SERDE-27`, `SEAM-21`, `IO-9`,
+ `BODY-32`.
### SHOULD- and MAY-level requirements declined for v1
diff --git a/docs/knowledge/notes/serde.md b/docs/knowledge/notes/serde.md
new file mode 100644
index 0000000..4553e56
--- /dev/null
+++ b/docs/knowledge/notes/serde.md
@@ -0,0 +1,11 @@
+# serde — notes
+
+Hand-written. `../harvested/serde.md` is what the documents say; this file is what the
+implementation found, and it wins. Each entry names the harvested entry it answers by that entry's
+stable key.
+
+## Reference
+- **A decoded JSON `String` can be UTF-8-tagged and invalid, and neither layer that produces it raises.** Beside `serde/b5e5efc8`, which puts the strictness burden on the witness: a witness asserting `String` cannot catch this, because an invalid-UTF-8 `String` *is* a `String`. Measured on Ruby 3.2.11, 3.3.12, 3.4.10 and 4.0.6 against json 2.19.9 (the gemspec floor) and 3.0.2 (the bundle's): `JSON.parse(%Q({"a":"\xff"}).b)["a"]` returns a `String` whose `#encoding` is UTF-8, whose `#bytes` is `[255]` and whose `#valid_encoding?` is `false`. Phase 3a's `#read_utf8` and `#read_string(encoding)` retag and apply no replacement policy, by their own stated contract, so nothing between the wire and the witness validates. Phase 7a's `Dexpace::Serde::JSON::Codec#load` therefore calls `#valid_encoding?` on the drained text and raises `Dexpace::Serde::DeserializationError` before parsing (`7a P7-6`). A *transcode* is the wrong repair: `"\xc3\xa9".b.encode(::Encoding::UTF_8, ::Encoding::BINARY)` raises `Encoding::UndefinedConversionError` for a valid two-byte `é`, because BINARY has no character semantics to convert from — so the retag-then-transcode recipe of `docs/knowledge/notes/io-and-byte-streams.md` (its key io-and-byte-streams/6eb5155f, a note and not a harvested rule) is retag, then **validate**, and the transcode step applies only when a declared charset is not UTF-8.
+ review · `docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-design.md` · high · sha:manual-phase7a-utf8-validation
+- **`JSON::Coder`'s constructor and its tolerance of an unknown option both changed between json 2.19.9 and 3.0, so an adapter that forwards caller options verbatim is configured differently on the two, silently on one and loudly on the other.** Adds to `serde/d3bef411` and `serde/b36d4403`, which describe the private-engine and fresh-factory contract the `Coder` makes satisfiable literally (`7a P7-4`), and to `serde/d15ade64`, the reason the floor is a gemspec line: measured on 2026-09-20 on 3.4.10 with json 2.19.9 and on 3.2.11 and 4.0.6 with json 3.0.2, `JSON::Coder.instance_method(:initialize).parameters` is `[[:opt, :options], [:block, :as_json]]` on 2.19.9 and `[[:key, :object_class], [:key, :array_class], [:key, :on_load], [:keyrest, :options], [:block, :as_json]]` on 3.0.2; `Coder.new(max_nestng: 4).dump([1])` returns `"[1]"` on 2.19.9 and raises `ArgumentError: unknown keyword: max_nestng` on 3.0.2; `Coder.new(encoders: {})` is accepted on 2.19.9 and refused on 3.0.2; `Coder.new(nil)` works on 2.19.9 and raises on 3.0.2; a duplicate key is accepted last-wins on 2.9.1, a warning on 2.19.9 and a `ParserError` on 3.0.2, while `Coder.new(allow_duplicate_key: false)` raises `ParserError` on both floored versions; and `Coder.new.dump(Object.new)` and `.dump(Time.at(0))` raise `GeneratorError` on both **without** `strict: true` — the `Coder` is strict on its own account, and `strict: true` is documentation of intent rather than a switch. Phase 7a's codec therefore validates options against its own allowlist, constructs the `Coder` with keywords only, fixes `allow_duplicate_key: false` unless the caller opts in, and never forwards `encoders:`. Design open question 4's premise ("`Coder.new` accepts unknown options silently") is true at the floor and false at 3.0.
+ review · `docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md` · high · sha:manual-phase7a-coder-option-drift
diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md
index 796611b..0d959d2 100644
--- a/docs/sdk-documentation/architecture.md
+++ b/docs/sdk-documentation/architecture.md
@@ -8,18 +8,19 @@ recovery layer and the error trail, phase 4c the stage pipeline, phase 5a the co
layer and the clock, phase 5c the tracing and metrics layer, phase 5b the logging facade and
redaction, phase 6a the retry layer — one policy core and its two stacks — phase 6c the
authentication layer, phase 6b the redirect layer with the two `standard` pipeline
-constructors, phase 7b the server-sent-events layer with the serde-boundary gate and phase 7c the
-pagination layer; the adapters are still to come, so this page is still a stub: it names the
+constructors, phase 7b the server-sent-events layer with the serde-boundary gate, phase 7c the
+pagination layer and phase 7a the serialization layer with the JSON codec; the adapters are still to
+come, so this page is still a stub: it names the
pages this tree will eventually hold and where each one's content will come from, so the plan for
-the documentation exists before the documentation does. Sixteen pages are real already, because
+the documentation exists before the documentation does. Seventeen pages are real already, because
their subjects are: [`quality-gates.md`](./quality-gates.md), [`http.md`](./http.md),
[`seams.md`](./seams.md), [`io.md`](./io.md), [`body.md`](./body.md),
[`execution-context.md`](./execution-context.md), [`recovery.md`](./recovery.md),
[`pipelines.md`](./pipelines.md), [`configuration.md`](./configuration.md),
[`tracing-and-metrics.md`](./tracing-and-metrics.md),
[`logging-and-redaction.md`](./logging-and-redaction.md), [`retry.md`](./retry.md),
-[`auth.md`](./auth.md), [`redirect.md`](./redirect.md), [`sse.md`](./sse.md) and
-[`pagination.md`](./pagination.md).
+[`auth.md`](./auth.md), [`redirect.md`](./redirect.md), [`sse.md`](./sse.md),
+[`pagination.md`](./pagination.md) and [`serde.md`](./serde.md).
Once `dexpace-core` and the first adapters ship, this page becomes the same kind of front door the
sibling Node SDK's `docs/sdk-documentation/architecture.md` is — package by package, seam by seam
— and the entries below turn from plain text into real links, one at a time, as each page is
@@ -165,6 +166,18 @@ layer phase 7b shipped; derives from `docs/sdk-design-ruby/07-pagination-sse-and
§7.2 and §7.1, read together with entries 6 and 18 of
`docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md`.
+[serde.md](./serde.md) — the serialization layer and the JSON codec: the witness protocol — the
+predicate pair, the decode context with its RFC 6901 pointer and one raise site, the three container
+combinators, the named boolean witness and the ISO-8601 witness with its stated precision domain —
+the three-state PATCH type and its decode combinator, the native-form encode walk and the `OMIT`
+sentinel that make tri-state PATCH structural, `Body.serialized`, the two response handlers supplied
+into `TypedResponse`, and `dexpace-serde-json`'s codec — its private engine per instance, its option
+allowlist, its failure model, the four encode profiles, the stream rule, and the materialisation
+ceiling a large body meets. Written against the layer and the gem phase 7a shipped; derives from
+`docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md` §3.4 and
+`docs/sdk-design-ruby/07-pagination-sse-and-serialization.md` §7.3, read together with entries 12, 13
+and 14 of `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md`.
+
[quality-gates.md](./quality-gates.md) — every blocking gate this SDK runs, what each protects,
and how to run it locally. Written against the build phase 0 shipped; derives from
`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`.
@@ -175,7 +188,8 @@ the seam's contract itself is already on [seams.md](./seams.md).
write-a-serde.md — implementing the `Serde` seam: the serializer/deserializer pair, the
`Tristate` PATCH convention, and the four encode profiles. Derives from
-`docs/sdk-design-ruby/07-pagination-sse-and-serialization.md`.
+`docs/sdk-design-ruby/07-pagination-sse-and-serialization.md`; the as-built reference implementation
+it will walk through is on [serde.md](./serde.md).
[pagination.md](./pagination.md) covers what write-a-paging-strategy.md was planned to hold —
the strategy contract (`#parse(response, template) -> Info`), the three built-ins over a
diff --git a/docs/sdk-documentation/serde.md b/docs/sdk-documentation/serde.md
new file mode 100644
index 0000000..869b436
--- /dev/null
+++ b/docs/sdk-documentation/serde.md
@@ -0,0 +1,447 @@
+# Serialization
+
+**As built by phase 7a, in `dexpace-core` and `dexpace-serde-json`, written against source on
+2026-09-20.** This page says what chapter 14 gives an SDK author today: the witness protocol design
+§10.14 substituted for the reference's reflective type token — one predicate pair, one decode context,
+four combinators and three scalar witnesses — the `Tristate` three-state PATCH type, the native-form
+encode walk that makes tri-state PATCH structural, `Body.serialized`, the two response handlers phase
+3b's `TypedResponse` was built to take, and the JSON codec that fills `dexpace-serde-json` and declares
+the `json >= 2.19.9` floor. What each is *required* to do is `docs/product-spec/14-serialization-serde.md`
+(`SERDE-1`–`SERDE-30`); how the design maps it to Ruby is
+`docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md` §3.4 and
+`docs/sdk-design-ruby/07-pagination-sse-and-serialization.md` §7.3, read together with entries 12, 13 and
+14 of `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md`; the per-requirement
+proof is `docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md`. Signatures live in
+`gems/dexpace-core/sig/dexpace/serde/` and `gems/dexpace-serde-json/sig/dexpace/serde/json/`, and this
+page does not restate them. Every example below was run against the built code on 4.0.6 and 3.2.11 and
+printed the same on both (the one difference is Ruby 3.4's `Hash#inspect` spelling, so the examples
+print strings and arrays). `S` is `Dexpace::Serde`, `T` is `Dexpace::Serde::Tristate`, `CODEC` is
+`Dexpace::Serde::JSON.default`, `ctx` is `S::DecodeContext.root` and `src(text)` is
+`Dexpace::IO::BufferedSource.of_bytes(text.b)` throughout; `Pet` and `PetPatch` are the two model
+classes under *The witness protocol*.
+
+**Two gems, one seam.** The codec seam — six methods, `.conforms?`, the registry — is phase 2's, in core.
+Everything on this page that is codec-agnostic (the protocol, the context, the combinators, `Tristate`,
+`Native`, `Instant`, the two handlers, `Body.serialized`) is core's; the codec itself is the adapter's.
+`Dexpace::Serde::JSON` appears in no core file, signature or test, and a core test proves it
+(`gems/dexpace-core/test/dexpace/serde/no_concrete_codec_test.rb`, `SEAM-2`).
+
+## The witness protocol
+
+A **witness** is any object answering `.dexpace_load(parsed, ctx)`: a model **class** implementing it
+as a class method, or a combinator **instance** implementing it as an instance method — one
+`respond_to?` predicate covers both, never a nominal test (`SERDE-5`, `SERDE-7`). The encode side is
+`#dexpace_dump`, a zero-argument instance method returning the value's codec-native form; it takes no
+context, because Ruby erases nothing about an object's class and reifies nothing about a container's
+element type, which is the asymmetry §7.3 argues. The two method names are public frozen Symbols so a
+code generator emits against a name this repository could rename.
+
+```ruby
+class Pet
+ attr_reader :id, :name, :tags
+
+ def self.dexpace_load(parsed, ctx)
+ h = ctx.object!(parsed)
+ new(id: ctx.integer!(h["id"], key: "id"),
+ name: ctx.string!(h["name"], key: "name"),
+ tags: S::List.of(String).dexpace_load(h["tags"], ctx.at("tags")))
+ end
+
+ def initialize(id:, name:, tags:) = (@id, @name, @tags = id, name, tags)
+ def dexpace_dump = { "id" => @id, "name" => @name, "tags" => @tags }
+end
+
+class PetPatch
+ def self.dexpace_load(parsed, ctx)
+ h = ctx.object!(parsed)
+ new(name: ctx.string!(h["name"], key: "name"),
+ nick: T.of(String).dexpace_load_field(h, "nick", ctx))
+ end
+
+ attr_reader :name, :nick
+ def initialize(name:, nick:) = (@name, @nick = name, nick)
+ def dexpace_dump = { "name" => @name, "nick" => @nick }
+end
+
+S.witness?(Pet) # => true
+S.witness?(S::List.of(Pet)) # => true
+S.witness?(->(parsed, ctx) { parsed }) # => false
+S.witness!(nil)
+# => Dexpace::InvalidArgumentError: a witness must respond to .dexpace_load(parsed, ctx); NilClass does not
+[S::WITNESS_METHOD, S::DUMP_METHOD] # => [:dexpace_load, :dexpace_dump]
+```
+
+A `#call`-shaped object is deliberately **not** a witness: `SERDE-5` requires an explicit runtime type
+witness and `SERDE-8` requires construction to fail fast without one, and a `#call` fallback would make
+every lambda a witness and both MUSTs unenforceable. The set of witnesses is **open**: a caller writes a
+`Set` witness or a discriminated-union witness in two lines, with no registration and no core change.
+`SERDE-23`'s tolerant decode falls out of the shape — a witness reads the keys it declares and never
+enumerates the object, so an unknown field is ignored with nothing written.
+
+## The decode context: `DecodeContext`
+
+`ctx` is `Dexpace::Serde::DecodeContext`, a frozen `Data` carrying the path from the document root
+(Strings for keys, Integers for indices, rendered by `#pointer` as an RFC 6901 pointer with `~0`/`~1`
+escaping) and the decode's target type **name**, and holding the **one** raise site for a shape failure —
+`Model.required!`'s discipline applied to the decode side, so `SERDE-13`'s "naming the target type" and
+`SERDE-21`'s nine refusals are properties of one method rather than of every witness anyone writes. It is
+emphatically not phase 4a's `Dexpace::Context`: a decode happens with no pipeline in sight.
+
+```ruby
+ctx = S::DecodeContext.root(target: Pet)
+ctx.target # => "Pet"
+ctx.at("tags").at(0).pointer # => "/tags/0"
+ctx.float!(1) # => 1.0 (SERDE-22: the one permitted widening)
+ctx.string!("") # => "" (SERDE-22: an empty string is a String)
+ctx.integer!("5", key: "id")
+# => Dexpace::Serde::DeserializationError: expected Integer at /id, got String
+ctx.object!(nil)
+# => Dexpace::Serde::DeserializationError: expected Pet (Hash) at /, got NilClass
+```
+
+The eight `!` methods — `#object!`, `#array!`, `#string!`, `#integer!`, `#float!`, `#boolean!`,
+`#present!` and `#error!` — accept exactly the matching shape and refuse every cross-shape coercion
+`SERDE-21` names: `"5"` never becomes `5`, `1.0` never becomes `1`, `true` never becomes `1`, `1` never
+becomes `true`, `"true"` never becomes `true`, `5` never becomes `"5"`. `#float!` is the one method with
+a permission — an Integer widens to a Float, which `SERDE-22` requires. `key:` appends one segment for
+the message only; `#at` is what a combinator uses to descend. The **root frame names the target**
+(`expected Pet (Hash) at /`), because a witness reached with a wire null calls `ctx.object!(nil)` and
+knows only the shape it wanted; a nested frame's target *is* its expected shape, so it renders the plain
+form (`expected String at /tags/0, got Integer`). The root's target is set by the codec's `#load` from
+the witness — never by a nil check inside `#load`, because `SERDE-20`'s `Tristate.of` and `Nullable.of`
+legitimately want a top-level null.
+
+## The combinators: `List`, `Map`, `Nullable`
+
+Each is a frozen `Data` built **by value** from a concrete element witness, and each *is* a witness, so
+they nest (`SERDE-6`). Construction runs every argument through the scalar table and
+`Dexpace::Serde.witness!`, so `List.of(nil)`, `List.of(Object.new)` and `List.of(::Time)` all raise at
+**construction** with an actionable message — `SERDE-8`'s fail-fast, earlier than the reference's
+binder-resolution failure; its "unresolved type variable" state is unreachable, because a combinator
+cannot exist without a concrete element. The ergonomic spellings §7.3 uses work verbatim: `String`,
+`Integer` and `Float` resolve through a private table to three scalar witnesses, and booleans are the
+named witness `Dexpace::Serde::BOOLEAN`, because Ruby has no `Boolean` class to key on and
+`List.of(TrueClass)` would read as a list of `true`s. `::Time` is deliberately **not** in the table — the
+ISO-8601 wiring is the adapter's (design §3.4) — so a caller wanting times writes
+`List.of(Dexpace::Serde::Instant)`.
+
+```ruby
+S::List.of(String).dexpace_load(%w[a b], ctx) # => ["a", "b"]
+S::Map.of(String, Float).dexpace_load({ "x" => 1 }, ctx).to_a # => [["x", 1.0]]
+S::Nullable.of(Integer).dexpace_load(nil, ctx) # => nil
+S::List.of(Pet) == S::List.of(Pet) # => true
+S::List.of(Object.new)
+# => Dexpace::InvalidArgumentError: a witness must respond to .dexpace_load(parsed, ctx); Object does not
+S::List.of(Pet).dexpace_load([{ "id" => 1, "name" => 2 }], ctx)
+# => Dexpace::Serde::DeserializationError: expected String at /0/name, got Integer
+```
+
+`Map.of` decodes **keys** through the key witness too — for JSON always `String`, but the witness is
+required rather than assumed, so a codec whose keys are not strings inherits the combinator unchanged.
+Every combinator follows the phase-1 construction pattern (`.new` and `.[]` private, a validating
+`.build` that `.of` and `#with` route through, structural equality) and answers a **fresh** collection,
+never the parsed one.
+
+## The three-state PATCH type: `Tristate`
+
+`Dexpace::Serde::Tristate` is a module included by three values — `ABSENT` (the key is missing), `NULL`
+(the key is present with an explicit null) and `Present` (the key carries a value) — so one type test
+covers the three (`SERDE-14`). The illegal fourth state, Present-of-null, is unrepresentable on **both**
+paths: `Present` validates in `#initialize`, so `.build`, the private `.new` and `.[]` all refuse nil, and
+`Dexpace::Model#with` routes a derivation through `.build` on every supported Ruby — including 3.2, where
+`Data#with` skips an `initialize` override.
+
+```ruby
+[T::ABSENT, T::NULL, T.present(1)].map(&:to_s) # => ["Absent", "Null", "#"]
+T.present(1).value # => 1
+T.from_nullable(nil).to_s # => "Null" (SERDE-18: never Absent)
+T.present(nil)
+# => Dexpace::InvalidArgumentError: value is required
+T.present(1).with(value: nil)
+# => Dexpace::InvalidArgumentError: value is required
+T::ABSENT.fold(on_absent: -> { :a }, on_null: -> { :n }, on_present: ->(v) { v }) # => :a
+```
+
+`SERDE-18`'s helpers are all there — `.absent`, `.null`, `.present(value)`, `.from_nullable(value)` (the
+nullable mapper that can never yield Absent), the three predicates, `#value_or_nil` and the three-way
+`#fold` — and the two sentinels print as `Absent` and `Null` rather than an object id (`SERDE-30`, taken).
+
+**Decoding is the combinator's, and it has two entry points.** `Tristate.of(element)` is a witness whose
+`#dexpace_load_field(hash, key, ctx)` reads the in-object case — the enclosing Hash answers `key?`
+directly, so a missing key is Absent, a present null is Null and a present value is Present of the
+element's decode at the key's own path (`SERDE-16`, `SERDE-17` with no field-default machinery) — and
+whose protocol `#dexpace_load(parsed, ctx)` is `SERDE-20`'s top-level case. `.of` and `.from_nullable`
+are two names on purpose: one is the decode-side combinator, the other a value mapper.
+
+```ruby
+w = T.of(String)
+w.dexpace_load_field({}, "x", ctx).to_s # => "Absent"
+w.dexpace_load_field({ "x" => nil }, "x", ctx).to_s # => "Null"
+w.dexpace_load_field({ "x" => "v" }, "x", ctx).value # => "v"
+w.dexpace_load(nil, ctx).to_s # => "Null"
+```
+
+## The encode walk: `Native` and `OMIT`
+
+`Dexpace::Serde::Native.of(value, encoders: {})` walks any value into codec-native Ruby — `Hash`,
+`Array`, `String`, `Integer`, `Float`, `true`, `false`, `nil` — in the one place where `SERDE-15`'s key
+omission, `SERDE-20`'s three degradations and `SERDE-9`'s loud failure on an unencodable value all live.
+Design §7.3 put the Absent-key omission in each model's own `#dexpace_dump`; the port puts it in the
+walk instead (`7a P7-9`), because `SERDE-19`'s named failure — "absent this wiring, Absent and Null
+become indistinguishable on the wire" — is exactly what a per-model convention produces when one model
+forgets, silently, in a PATCH. A model that omits its own Absent keys still works; the walk has nothing
+to drop. The rules, in order: native scalars pass through (a mutable String copied and frozen, nothing
+retagged); anything answering `#dexpace_dump` is replaced and **re-walked**; a Hash has each value walked
+and an entry whose value is `OMIT` dropped, with String and Symbol keys coerced to Strings and anything
+else refused; an Array has each element walked and an `OMIT` written as `nil`; at the top level `OMIT`
+is `nil`; a class in `encoders:` — exact class first, then the first `is_a?` match — is replaced and
+re-walked; and anything else raises `SerializationError` **naming the class**, the loud failure
+`::JSON.generate` measurably does not give (it returns the object's `#inspect` as a JSON string).
+
+```ruby
+S::Native.of(PetPatch.new(name: "x", nick: T::ABSENT)).to_a # => [["name", "x"]]
+S::Native.of(PetPatch.new(name: "x", nick: T::NULL)).to_a # => [["name", "x"], ["nick", nil]]
+S::Native.of([T::ABSENT, T.present("v")]) # => [nil, "v"]
+S::Native.of(T::ABSENT) # => nil
+S::OMIT.to_s # => "Omit"
+S::Native.of(Object.new)
+# => Dexpace::Serde::SerializationError: Object is not a codec-native value: it answers no #dexpace_dump and no encoder is configured for it
+S::Native.of(Time.utc(2026, 9, 10), encoders: { Time => ->(t) { t.iso8601 } }) # => "2026-09-10T00:00:00Z"
+```
+
+`Native` and `OMIT` are public because a second codec adapter (`dexpace-serde-oj`, post-v1) calls `.of`
+and inherits tri-state encoding, which is design §3.4's "no second code path in core". Core ships the
+`encoders:` table **empty**; the adapter fills it. A Symbol value is not native and raises; a BINARY
+String is handed to the generator as it is, which refuses invalid UTF-8 (a `SerializationError`) and
+warns on valid UTF-8 tagged BINARY — encode text as UTF-8 before it reaches a codec. The walk returns
+fresh collections and never aliases a caller's. A cyclic graph recurses without bound and is the
+caller's mistake.
+
+## The ISO-8601 witness: `Instant`
+
+`Dexpace::Serde::Instant` is core's, because a witness is codec-agnostic and a second codec would
+otherwise write a second one; its *default wiring* as the encoder for `::Time` is the adapter's.
+`.dexpace_load` parses through `Time.iso8601` (from `time`, which the `Dexpace/NoTimeParse` cop does not
+ban; phase 5a's `HTTPDate` is RFC 1123, a different grammar) and refuses the lax forms `"2026-09-10"`,
+`"2026-09-10 12:00:00"`, `""` and a non-String, each naming `Time (ISO-8601)` at the field's path.
+`.dexpace_dump` renders `#iso8601(6)`.
+
+**The precision domain (`7a P7-8`).** `Time#iso8601(n)` truncates rather than rounds, so `SERDE-24`'s
+round trip holds exactly for any `Time` whose `subsec` is an exact multiple of one microsecond — every
+`Time` this SDK constructs (`Time.utc(...)`, `Time.at(sec, usec, :usec)`) and every `Time` `Instant`
+decodes — and is lossy outside it, including the ordinary-looking `Time.new(2026, 9, 10, 12, 0,
+0.123456, "+02:00")`, whose Float second is stored as the exact rational `0.12345599999…` and renders one
+microsecond low. The port does not round instead: that would be a second date formatter beside
+`HTTPDate`.
+
+```ruby
+S::Instant.dexpace_dump(Time.utc(2026, 9, 10, 12)) # => "2026-09-10T12:00:00.000000Z"
+S::Instant.dexpace_load("2026-09-10T12:00:00Z", ctx).utc? # => true
+S::Instant.dexpace_dump(Time.new(2026, 9, 10, 12, 0, 0.123456, "+02:00")) # => "2026-09-10T12:00:00.123455+02:00"
+t = Time.at(1_757_505_600, 123_456, :usec).utc
+S::Instant.dexpace_load(S::Instant.dexpace_dump(t), ctx) == t # => true
+```
+
+## The JSON codec: `Dexpace::Serde::JSON`
+
+`gems/dexpace-serde-json` is the workspace's second real gem: `Dexpace::Serde::JSON::Codec` implements
+phase 2's six seam methods over one private `::JSON::Coder`, the gemspec declares `json >= 2.19.9` —
+**the only place in the repository that floor may be stated** — and the entry file re-asserts the floor at
+require time as `Dexpace::SeamError` (`7a P7-7`), because `bundler-audit` runs in this repository's CI and
+never in a consumer's process, and an unbundled `require "dexpace/serde/json"` on a stock Ruby 3.3 or
+3.4 activates the interpreter's default json (2.7.2 / 2.9.1), which has no `JSON::Coder` at all — and a
+stock Ruby 4.0 activates 2.18.0, which has one and is still below the floor; `json/floor_test.rb` drives
+the assertion against each interpreter's default json in a bundler-stripped child process. Requiring
+the entry file registers the codec under `:json` with `core: "~> 0.0"`, design §2.4's version-skew guard.
+
+`Dexpace::Serde::JSON.default` is a factory — a **fresh** codec on every call (`SERDE-25`) — and
+`.build(options)` takes one positional Hash validated against an allowlist of five: `max_nesting:`,
+`allow_nan:`, `allow_duplicate_key:`, `script_safe:` and `encoders:`. An unknown key is the SDK's own
+`InvalidArgumentError`, because json 2.19.9 swallowed an unknown `Coder` option silently and json 3.0
+refuses it with a keyword error. Two options are the codec's and not the caller's: `strict: true` always
+(belt and braces beside `Native`, which refuses every non-native value before the generator sees it) and
+`allow_duplicate_key: false` unless the caller opts in, because a duplicate key is accepted-last-wins on
+json 2.9, a warning on 2.19.9 and a `ParserError` on 3.0, and the explicit option makes it one
+`DeserializationError` across the range. `encoders:` never reaches the `Coder`; it replaces the default
+table, which renders `::Time`, `::DateTime` and `::Date` as ISO-8601 through `Instant` (`SERDE-24`).
+
+```ruby
+Dexpace::Serde.conforms?(CODEC) # => true
+CODEC.media_type.render # => "application/json"
+CODEC.dump_string(Pet.new(id: 7, name: "Ré", tags: %w[a])) # => "{\"id\":7,\"name\":\"Ré\",\"tags\":[\"a\"]}"
+CODEC.dump_bytes({ "a" => "é" }).encoding.name # => "ASCII-8BIT"
+pet = CODEC.load(src("{\"id\":7,\"name\":\"Ré\",\"tags\":[\"a\"],\"extra\":true}"), Pet)
+[pet.class, pet.id, pet.name, pet.tags] # => [Pet, 7, "Ré", ["a"]]
+CODEC.dump_string(PetPatch.new(name: "x", nick: T::ABSENT)) # => "{\"name\":\"x\"}"
+CODEC.dump_string(PetPatch.new(name: "x", nick: T::NULL)) # => "{\"name\":\"x\",\"nick\":null}"
+CODEC.load(src("{\"name\":\"x\"}"), PetPatch).nick.to_s # => "Absent"
+CODEC.dump_string({ "at" => Time.utc(2026, 9, 10, 12) }) # => "{\"at\":\"2026-09-10T12:00:00.000000Z\"}"
+S::JSON.default.equal?(S::JSON.default) # => false
+S::JSON::MINIMUM_JSON_VERSION # => "2.19.9"
+Dexpace::Serde.registered_keys # => [:json]
+S::JSON.build(max_nestng: 4)
+# => Dexpace::InvalidArgumentError: unknown codec option(s) :max_nestng; the accepted options are :max_nesting, :allow_nan, :allow_duplicate_key, :script_safe, :encoders
+```
+
+**The failure model** (`SERDE-9`, `SERDE-10`, `SERDE-12`, `SERDE-13`, `7a P7-6`). An unencodable value
+raises `SerializationError`; malformed input raises `DeserializationError` with the parser's error as
+`#cause`; a wire null into a non-null target names the target; invalid UTF-8 in the payload is refused
+before parsing, because 3a's `#read_utf8` retags without validating and `::JSON.parse` accepts invalid
+UTF-8 and returns a String that claims an encoding it does not have (`P7-6`); and a genuine stream I/O
+error propagates **unwrapped**, structurally — the codec rescues `::JSON::JSONError` around the parse
+alone, whose ancestry is `[ParserError, JSONError, StandardError]` with `IOError` nowhere in it, so a
+`Dexpace::StreamError` cannot be caught by it.
+
+```ruby
+CODEC.dump_string(Object.new)
+# => Dexpace::Serde::SerializationError: Object is not a codec-native value: it answers no #dexpace_dump and no encoder is configured for it
+begin; CODEC.load(src("{not json"), Pet); rescue S::DeserializationError => e; [e.class, e.cause.class]; end
+# => [Dexpace::Serde::DeserializationError, JSON::ParserError]
+CODEC.load(src("null"), Pet)
+# => Dexpace::Serde::DeserializationError: expected Pet (Hash) at /, got NilClass
+CODEC.load(src("{\"name\":\"\xff\"}".b), Pet)
+# => Dexpace::Serde::DeserializationError: the payload is not valid UTF-8 (RFC 8259 §8.1); refusing to parse it
+failing = Object.new
+def failing.read_utf8(*) = raise Dexpace::StreamError, "connection reset"
+begin; CODEC.load(failing, Pet); rescue Dexpace::StreamError => e; [e.class, e.is_a?(S::Error)]; end
+# => [Dexpace::StreamError, false]
+```
+
+**The four encode profiles and the stream rule** (`SEAM-20`, `SEAM-21`, `SERDE-3`, `SERDE-4`, `7a
+P7-5`). `#dump_string` answers a fresh UTF-8 String and `#dump_bytes` the same bytes BINARY-tagged;
+`#dump_to(value, sink)` writes into a caller-owned `#write` sink and answers the count; `#dump_into(value,
+buffer, offset:)` writes at an offset into a **mutable BINARY String** and answers the count, with an
+explicit fit check — `String#[]=` silently *grows* a String on an over-long payload — raising
+`::IndexError` (distinct from the serde type, `#cause` nil even inside a caller's rescue) on an overflow
+or an out-of-range offset and leaving the buffer untouched. Ruby's `IO::Buffer` is refused with
+`InvalidArgumentError`: it warns through `Warning.warn` on construction at every level, which the suite's
+fatal-warnings base turns into an error, and its `#set_string` raises `ArgumentError` where `String#[]=`
+raises `IndexError` (`P7-5`). **The codec closes nothing** — not the sink, not the source it reads to
+EOF, not the buffer — which is the third ownership rule beside `IO-6`'s and `BODY-8`'s, phase 3 left to
+this layer, and the adapter's suite asserts it against close-counting trackers under every option.
+
+```ruby
+buffer = ("\0" * 12).b
+CODEC.dump_into({ "a" => 1 }, buffer, offset: 2) # => 7
+buffer # => "\x00\x00{\"a\":1}\x00\x00\x00"
+CODEC.dump_into({ "a" => 1 }, ("\0" * 4).b, offset: 0)
+# => IndexError: 7 bytes at offset 0 do not fit a 4-byte buffer
+sink = StringIO.new(+"".b)
+CODEC.dump_to([1, 2], sink) # => 5
+sink.closed? # => false
+```
+
+**`#load` materialises the whole text (`7a P7-1`).** `SERDE-27` asks a decoding handler to stream the
+body "without first materializing the whole body", and no version of the `json` gem at or above the
+floor can be handed a stream: `JSON.parse` raises `TypeError` on a `StringIO`, and the one IO-accepting
+entry point, `JSON.load`, is banned by design §3.4 for its `create_additions` hazard. So `#load` drains
+the source to EOF with 3a's `#read_utf8` under `Dexpace::IO.max_materialized_bytes` (64 MiB by default,
+checked incrementally), and **a response body above that ceiling raises `Dexpace::StreamError`**, an
+`::IOError`, unwrapped past the codec and past the handler's close — the documented limit a caller
+streaming a large JSON response meets. A caller wanting a larger body streams it through
+`Response#body.source` rather than through a typed handler. The seam already takes the source, so an
+adapter whose library has a pull parser (`dexpace-serde-oj`, post-v1) satisfies the clause with no change
+to core, to the handlers or to the seam.
+
+## `Body.serialized`
+
+`SERDE-2`'s factory, the ninth beside phase 3b's eight: a value plus a serde, with the serde's declared
+media type as the body's — a `MediaType` passed through, a String parsed through phase 1's
+`MediaType.parse`, and a nil or empty one refused naming the codec's class, because "MUST NOT be
+defaulted to a format-agnostic constant" means there is no fallback to fall back to. It returns a
+replayable `BytesBody` that knows its exact length. The default `Content-Type` travels as the body's
+media type; the header itself is the transport's to stamp on the wire when the caller set none
+(`TRANSPORT-10`, phase 8).
+
+```ruby
+body = Dexpace::Body.serialized(PetPatch.new(name: "Ré", nick: T::ABSENT), serde: CODEC)
+[body.class, body.media_type.render, body.content_length, body.replayable?]
+# => [Dexpace::BytesBody, "application/json", 14, true]
+```
+
+## The two response handlers
+
+Both are `Dexpace::_ResponseHandler`s — `#call(response) -> value` — supplied **into** phase 3b's
+`TypedResponse` and never replacing it: 3b's memo and its flip-only mutex are its, and a handler runs
+outside that lock by construction. Both are frozen `Data`s built through `.build`, with
+`Dexpace::Serde.witness!` at construction so a bad witness fails there rather than at first body access,
+which matters because `TypedResponse` is lazy.
+
+**`DecodingHandler.build(serde:, witness:)`** is `SERDE-27`: it hands `#load` the body's own `#source`
+and copies nothing; closes the response in an unguarded `ensure` on **every** path (the handler's subject
+is the response; the codec's, under `SERDE-3`, is the stream — two rules, two subjects); surfaces a
+missing **or empty** body as a `DeserializationError` naming the target (`an anonymous witness` for a
+`Class.new` one, which has no name to carry), screened before `#load` with
+`BufferedSource#eof?`, a non-consuming probe (never `#content_length`, which is `-1` for every
+unknown-length body, and never a parser message, which differs across json versions); and rescues
+nothing, so the codec's chained failures and an unwrapped I/O error both pass through. Which bodies it
+can read, stated because `Body#source`'s default raises: `ResponseBody`, `ResponseLoggingBody` and
+`BufferBody` — a `BytesBody` (`Body.bytes`, `Body.string`) raises `StreamError` naming the class, and
+`Body.buffer` is the readable in-memory spelling.
+
+**`StatusAwareHandler.build(serde:, witness:, factory:)`** is `SERDE-28`: a 2xx delegates to a
+`DecodingHandler` (one implementation of `SERDE-27`); a 4xx/5xx buffers the body through phase 4b's
+`Recovery.buffer_error_body` — the one buffering call site, which closes the live response itself, so
+this branch adds no second close — and raises `factory.call(buffered)`, `ProtocolError.for` by default,
+`cause: nil`; and anything else (a 1xx, an unfollowed 3xx such as a 304) closes the response and raises
+a `DeserializationError` whose message leads with the status code and carries the **raw** `ETag` and
+`Location` values, parsed by nothing. `factory:` is the same keyword `Recovery::ErrorMappingStep` takes,
+so a generated SDK substitutes its typed errors — and decodes the buffered error body with its own
+witness — with the hook it already knows.
+
+The five responses the example reads are built over `Body.buffer` — the readable in-memory body — by one
+helper, from the codec's own media type; a real transport's `ResponseBody` reads the same way.
+
+```ruby
+def response_over(status, text, headers: Dexpace::Headers::EMPTY_INBOUND)
+ buffer = Dexpace::IO::Buffer.new
+ buffer.write(text.b)
+ request = Dexpace::Request.build(method: "GET", url: "https://host/v1/pets/7", headers: Dexpace::Headers::EMPTY)
+ Dexpace::Response.build(request: request, protocol: Dexpace::Protocol::HTTP_1_1, status: status, headers: headers,
+ body: Dexpace::Body.buffer(buffer, media_type: CODEC.media_type))
+end
+ok_200 = response_over(200, '{"id":7,"name":"Ré","tags":["a"]}')
+not_found_404 = response_over(404, '{"error":"gone"}')
+not_modified_304 = response_over(304, "", headers: Dexpace::Headers.inbound_builder.add("etag", '"v1"').build)
+empty_200 = response_over(200, "")
+empty_list_200 = response_over(200, "[]")
+
+handler = S::StatusAwareHandler.build(serde: CODEC, witness: Pet)
+typed = Dexpace::TypedResponse.new(response: ok_200, handler: handler)
+typed.status.code # => 200
+typed.value.name # => "Ré"
+typed.value.equal?(typed.value) # => true (HTTP-44: decoded once, memoised)
+begin; Dexpace::TypedResponse.new(response: not_found_404, handler: handler).value; rescue Dexpace::ProtocolError => e; [e.status.code, e.response.body_string, e.response.body_string]; end
+# => [404, "{\"error\":\"gone\"}", "{\"error\":\"gone\"}"] (twice, after the live response closed)
+Dexpace::TypedResponse.new(response: not_modified_304, handler: handler).value
+# => Dexpace::Serde::DeserializationError: 304 Not Modified: not decoded into Pet, only a 2xx body is (SERDE-28); etag: "v1"
+Dexpace::TypedResponse.new(response: empty_200, handler: handler).value
+# => Dexpace::Serde::DeserializationError: no body to decode into Pet: the response carried none (SERDE-27)
+S::DecodingHandler.build(serde: CODEC, witness: S::List.of(Pet)).call(empty_list_200) # => []
+```
+
+The composed path a generated SDK walks — `Dexpace::Operation` → `Pipeline.standard` → `TypedResponse`
+over `StatusAwareHandler` over the real codec — is asserted end to end in
+`gems/dexpace-serde-json/test/dexpace/serde/json/composition_test.rb`, over an in-memory transport; its
+socket twin is phase 8a's.
+
+## What is deliberately not here
+
+- **A codec that streams.** `7a P7-1`: `#load` materialises the text under the 64 MiB ceiling and a body
+ above it is a `StreamError`; an adapter with a pull parser satisfies `SERDE-27`'s clause with no core
+ change, and `dexpace-serde-oj` is the named candidate (`docs/first-release.md`).
+- **A `::Time` entry in core's scalar table.** The ISO-8601 wiring is the adapter's per design §3.4; a
+ core mapping would make it every codec's default. `List.of(Dexpace::Serde::Instant)` is the spelling.
+- **A `#call`-shaped witness.** `SERDE-5` and `SERDE-8`, above. Phase 2's `FakeCodec` drives its witness
+ through `#call` because it predates the protocol, which is why core's handler tests use a named class
+ answering both.
+- **A `DumpContext`.** Encoding takes no context; the asymmetry is §7.3's own argument.
+- **A per-type cache.** `SERDE-29`'s cache clause has no subject: the witness is supplied per call and
+ nothing is memoised by type, which is what makes one frozen codec safe to share.
+- **Ruby's `IO::Buffer` as a `#dump_into` target.** `7a P7-5`: a mutable BINARY String *is* Ruby's byte
+ array, and `IO::Buffer` warns on construction and raises a different class on overflow.
+- **A `Content-Type` header stamped by `Body.serialized`.** The media type is the body's; the header is
+ the transport's to write when the caller set none (`TRANSPORT-10`).
+- **Parsing `ETag` or `Location` in the 304 message.** `HTTP-48` is unbuilt and a release decision; the
+ handler copies the raw values, so a malformed server `ETag` stays a diagnostic rather than becoming a
+ second failure.
diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
index a372a99..431a88d 100644
--- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
+++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
@@ -2274,7 +2274,14 @@ design.
and hands forward the question of whether the other four declared files are empty too. A shipped `.rbs`
that declares nothing passes `rbs validate`, passes `steep check` and tells a consumer's typechecker
nothing, which is the failure mode `NFR-3` exists to prevent and which nothing reports. **Code half:
- partial** — `#media_type`'s only. Touches `SERDE-2`, `NFR-3`, `NFR-13`.
+ partial** — `#media_type`'s only. Touches `SERDE-2`, `NFR-3`, `NFR-13`. [2026-09-20, 7a: premise false on
+ the tree — all four phase-2 `sig/` files have carried real bodies since c881f92 (#44):
+ `sig/dexpace/serde.rbs` declared `interface _Codec` at `Dexpace::_Codec` with all six methods typed, and
+ the three error files declare `class Error < ::StandardError; include Dexpace::Error` and its two
+ subclasses; 7a edited `_Codec` in place (`#media_type` to `(Dexpace::MediaType | String)`, `#load` over
+ `Dexpace::Serde::_Witness`, `#dump_to` to `Integer`) rather than writing a second one, so there is no
+ empty declaration to find and the "no gate can see it" hazard has no instance here; no action for
+ phase 10.]
- **`Dexpace::Protocol.parse` has no alias for `"http/1.0"`, so a real `HTTP/1.0` response makes
`ResponseMapper` raise.** Handed forward by 8a, measured at its plan `:233` and restated at `:4323`. Only
`http/1.1`, `http/2` and `http/2.0` fold into a recognised wire form, so `native.http_version` produces
@@ -2542,6 +2549,32 @@ design.
baseline. Touches `IO-11`, `IO-38`, `SSE-2`, `SSE-39`. Routed by 7b's checklist; referred to by date
and content, never by ordinal; not in phase 10's design's disposition table, which dispositions it at
execution.
+- **`gates:clean_bundle` installs into the interpreter's own gem directory, and the first adapter with a
+ third-party dependency makes that a network fetch on two matrix rows.** Found 2026-09-20 by phase 7a's
+ implementation, the first to run the gate against a gem whose gemspec declares a third-party gem
+ (`json >= 2.19.9`). The gate's scratch `Gemfile` holds the adapter and core by `path:` and runs
+ `bundle install --quiet` with no `--local` and no `BUNDLE_PATH`, so Bundler resolves `json` from
+ rubygems.org into the running interpreter's gem directory whenever no installed `json` satisfies the
+ floor — which on 2026-09-20 was true of 3.3.12 (default 2.7.2) and 3.4.10 (default 2.9.1), both of
+ which now hold a downloaded json 3.0.2 they did not before the run, while 3.2.11 and 4.0.6 already
+ held 3.0.2 beside their stock 2.6.3 / 2.18.0. Correct as the phase-0 gate's own behaviour (CI does the
+ same) and permitted for that gate run alone, but a gate that writes into a developer's interpreter and
+ needs the network on some rows and not others is a repair candidate: pin the scratch bundle's
+ `BUNDLE_PATH` under a scratch directory (`Dir.mktmpdir` already holds the Gemfile) so the fetch lands
+ beside the Gemfile and nothing outside the run changes. Phase 8a's Task 23 owns every edit to
+ `clean_bundle_check` this wave and may fix it there, in which case this bullet is simply closed.
+ Touches `NFR-1`, `NFR-2`, `NFR-10`, `NFR-12`. Recorded by 7a on its docs branch; referred to by date
+ and content, never by ordinal.
+- **`rbs_collection.yaml`'s header comment is stale: "json arrives with dexpace-serde-json's codec in
+ phase 7", and phase 7a added no row.** Found 2026-09-20 by phase 7a's implementation and routed here
+ by its review round 0 (R0-1): `json`'s signatures are rbs's own stdlib set — `rbs collection install`
+ already resolves `json` with `source: type: stdlib` — and json 3.0.2 ships no `sig/`, so the codec's
+ one `JSON::Coder` reference is settled in the Steepfile's `:serde_json` target and the collection file
+ needs nothing from 7a. The file is a shared one outside 7a's bounds that phase 8a rewrites with its
+ first row (`net-http`), so 7a's correction of the sentence was dropped rather than merged ahead of
+ that row; whichever lane adds the first row rewrites the sentence and closes this bullet, and if none
+ does before phase 10, the repair is a one-comment edit. No gate reads the comment. Touches nothing
+ normative. Recorded by 7a on its docs branch; referred to by date and content, never by ordinal.
**2026-09-13** — **Execution order amended by the roadmap-level generator-fitness review, which read the
plan end to end against one question: will a generated OpenAPI client be able to use this?** No cell of
@@ -4142,3 +4175,182 @@ combined tree's counts are `CLAUDE.md`'s: eighteen gates, the test suite at 3,14
assertions, 0 failures, 0 errors, 0 skips and 99.98 % line coverage on 4.0.6 at the tests tip. Re-proven
at every rebased tip on 4.0.6 and the matrix rows before the push, `gates:serde_boundary` with the
pagination rows guarded at every one of the three.
+
+**2026-09-20** — **Phase 7a implemented**, as three stacked branches against issue #26: code, tests,
+documentation, cut from `main` at `c53638b`, which holds every phase through 6b — the first of phase
+7's three sub-phases to be built, in parallel with 7b, 7c and 8a on the same base, and the first phase
+in the roadmap to write into two gems. `dexpace-core` carries the serialization layer beside the
+thirteen layers before it — eleven new `lib/` files under `serde/`: `decode_context.rb`, `witness.rb`
+(reopening phase 2's `Dexpace::Serde` for `WITNESS_METHOD`, `DUMP_METHOD`, `.witness?` and `.witness!`),
+`native.rb` (`Native` and `OMIT`), `scalars.rb` (the private table and the public `BOOLEAN`),
+`tristate.rb` (`ABSENT`, `NULL`, `Present`, the private `Combinator` behind `Tristate.of`), `list.rb`,
+`map.rb`, `nullable.rb`, `instant.rb`, `decoding_handler.rb` and `status_aware_handler.rb` — every one
+with a `sig/` mirror and a `test/` mirror (no new `private_constant` file: the layer's private constants
+all live inside public files), plus `tristate_decode_test.rb`, `body_serialized_test.rb` and
+`no_concrete_codec_test.rb` beside the mirrors; one earlier file widened in place, 3b's `http/body.rb`
+gaining `Body.serialized(value, serde:)`, the ninth factory; phase 2's `sig/dexpace/serde.rbs` edited in
+place — its `interface _Codec` was never empty (the design's headline finding rests on a false premise
+and is withdrawn in the As-built addendum; the phase-10 inbound bullet at the `_Codec` residue carries a
+dated bracketed correction) — with `#media_type` widened to `(Dexpace::MediaType | String)`, `#load`
+narrowed over a new `Dexpace::Serde::_Witness` and `#dump_to` to `Integer`; and the entry file's
+eleven-line `# Phase 7a:` block after 6b's, in dependency order. **`dexpace-serde-json` is the
+workspace's second real gem**: `lib/dexpace/serde/json/codec.rb` (`Codec`, the six seam methods over one
+private `::JSON::Coder` per instance, a five-key option allowlist over one positional Hash, `strict:
+true` and `allow_duplicate_key: false` fixed by the codec, `encoders:` never forwarded), the entry file
+rewritten with `MINIMUM_JSON_VERSION` asserted at require time as `SeamError`, `REQUIRED_CORE = "~> 0.0"`
+on the registration under `:json`, and `.default` / `.build`; the gemspec's `json >= 2.19.9` line — the
+first `NFR-2` third-party half spent, and the first time `gates:gemspec_audit`, `gates:require_allowlist`
+and `gates:clean_bundle` ran against a gem carrying one, all three green on every matrix row; the
+Steepfile's `:serde_json` target alone downgrading `Ruby::UnknownConstant` to `:information` (rbs 4.2.0
+declares no `JSON::Coder` and json 3.0.2 ships no `sig/`, so the second route of the manager's decision
+(1), with the ivar typed `untyped`); the smoke test reshaped to snapshot after `require "dexpace"`; and
+four new suites beside it
+(`codec_test.rb`, `codec_load_test.rb`, `defaults_test.rb`, `seam_conformance_test.rb` over the
+phase-9 lift target `test/support/serde_seam_assertions.rb`, `composition_test.rb` walking
+`Operation` → `Pipeline.standard` → `TypedResponse` over a recording lambda transport), with two new
+close-counting doubles. The surface manifests were regenerated once, core 1 137 → 1 210 and the adapter
+2 → 15, all 86 rows read against the object model and no private constant among them. **Five existing
+core pins changed on the code branch as pins the registration invalidated** — an adapter that
+self-registers at require time makes "starts empty on a bare require" false in `rake test:gems`'s one
+process — `seam_surface_test.rb`'s two seam-iterating pins and `serde_test.rb`'s "starts empty" pin now
+assert in a child process that requires `dexpace` alone (`instrumentation/independence_test.rb`'s
+`IO.popen` shape; 8a is converting the same two `seam_surface_test.rb` lines, and the reconcile pass
+keeps one copy), and `serde_test.rb`'s two swap pins assert the override is gone rather than that
+nothing resolves. **What the plan did not know, and the tree decided** (the checklist's "Deviations from
+the plan", thirty-one items; the design's As-built addendum P7-61–P7-72): the empty-body screen is
+`BufferedSource#eof?`, never `#content_length` or a parser message; `FakeResponseBody` is the counting
+body and no `CountingResponse` double exists; `.build` plus `private_class_method :new, :[]` with
+validation in `#initialize` on every `Data`; `DecodeContext.build` is public; `StatusAwareHandler`'s
+members are its three build keywords; a `#call`-shaped object is not a witness; the composition slice
+runs `Pipeline.standard`, not `.direct`, over a lambda, since core's `FakeTransport` is out of another
+gem's reach; the `SEAM-2` scan reads code through Ripper, because two core comments name the adapter;
+and `JSON::Coder` is strict on its own account, so `strict: true` is documentation and `Native` is the
+observable layer (guard 19 stays green on every row, an equivalent mutant). **Every guard the brief
+lists was run**: thirty-four single-edit mutations on 4.0.6 and 3.2.11, thirty-one caught on both rows,
+guard 3 (`Data#with` in place of `Model#with`) caught on 3.2.11 alone as predicted, and two equivalent
+mutants recorded with their reasons (19, and 21 — the parse rescue's scope is the parse alone, so
+widening it cannot reach the drain; 21.5 widens the drain and is red); plus the four gate mutations
+(a second `add_dependency`, `require "json"` in a core file, a `::JSON::Coder` ivar type, a `::JSON`
+type in a public signature), each red under its gate. **Postponed items that landed:** phase 3b's
+`TypedResponse` has its two handlers; phase 2's `_Codec` clauses are settled; the knowledge-lookup
+skill's thirteenth audit row gained `constraints,conclusions` (the design's narrowness finding, a
+one-cell edit). **What stays where it is:** `SERDE-27`'s no-materialization clause (`7a P7-1`, the
+`docs/first-release.md` entry, whose first owed half — the documented ceiling behaviour — closed with
+`docs/sdk-documentation/serde.md` and whose second waits on phase 8); `dexpace-serde-oj`'s second motive
+(already on its post-v1 entry); the `Present` fourth-state closure on the 3.2 floor is proven by
+`test:gems` on 3.2.11, where the guard alone goes red. One new phase-10 inbound bullet, by date and
+content: `gates:clean_bundle` installs into the interpreter's gem directory and fetched json 3.0.2 from
+rubygems.org into 3.3.12's and 3.4.10's on this run. `CLAUDE.md` gains 7a's built-phase sentence, the
+layer paragraph, 184 → 195 `lib/` files, fifteen checklists, the gem's `lib/` sentence and five
+"Constraints that will bite" lines; `docs/sdk-documentation/serde.md` is the fifteenth page, every
+example run on 4.0.6 and 3.2.11; `docs/knowledge/notes/serde.md` is new with two entries (the UTF-8
+validation the design drafted, and the json 2.19.9 → 3.0 `Coder` option drift the build measured).
+Gates at the docs tip on 4.0.6: all seventeen green, `test:gems` 2 944 runs / 68 966 assertions /
+0 skips at 99.96% line coverage (the counts after review round 2's five added cases), the honest RuboCop clean, `rbs:validate` and `steep` clean, YARD
+100%; the matrix subset green on 3.2.11, 3.3.12 and 3.4.10. The consolidation of P7-1–P7-9 and
+P7-61–P7-72 into design §10 is phase 10's and a human's; `docs/deviations.md` is untouched.
+
+**2026-09-20, review round 1 of the phase-7a stack.** Round 0 returned `changes_requested` with two
+should-fix findings and three nits, nothing touching `lib/`. The first should-fix was a scope breach
+on the code branch: the feat commit had rewritten three lines of `rbs_collection.yaml`'s header comment
+to correct its stale "json arrives with the codec in phase 7" sentence — a shared file the brief lists
+out of bounds for 7a, which phase 8a rewrites with its first row, and which no gate reads — so the
+hunk is dropped in a `fix:` commit, the file is as `main` has it, and the stale sentence is one new
+phase-10 inbound bullet by date and content, for whichever lane adds the first row to close. The second
+was a survivor among the forty-two mutations the round ran: dropping the codec's explicit
+`allow_duplicate_key: false` default left every suite green on 4.0.6 and 3.2.11, because json 3.0.2 —
+the bundle's version on every row — refuses a duplicate key by default, and regressed to a
+warning-plus-last-wins only at the 2.19.9 floor, which no gate row runs. The option is now pinned at
+the keyword level: `codec_test.rb`'s fourth nested class, `CoderKeywordsTest`, runs a child process
+that prepends a recorder onto `::JSON::Coder`'s singleton class (a permanent patch to a library class,
+so never in the suite's own process — `context_store_config_test.rb`'s shape) and asserts every keyword
+`.new` receives across four constructions. The battery is thirty-five, thirty-three caught on both rows,
+guard 3 on 3.2.11 alone and guard 21 the one equivalent mutant: guard 34 is the round's, red on 4.0.6
+with json 3.0.2 and on 3.4.10 with json 2.19.9 pinned unbundled, and guard 19 (`strict: true` dropped),
+equivalent behaviourally, is red at the keyword level through the same pin. The nits — the five
+response fixtures `serde.md`'s last example used without defining, now built in the block; the
+`CLAUDE.md` sentence counting three serde pins in the child process where one is and two swap pins
+assert the override gone; and the sentence above, which called the Steep relaxation "the manager's
+route (1)" where it is the second route of decision (1) — are corrected in place. No ledger row is
+added: the design's As-built addendum carries a round-1 paragraph saying the round found no behaviour
+the document states that the code fails to honour.
+
+**2026-09-20, review round 2 of the phase-7a stack.** Round 1 returned `changes_requested` with two
+should-fix findings and two nits. Both should-fixes were survivors among the fifty-nine mutations the round
+ran — behaviours the design states and the checklist claimed pinned, where the code was right and the proof
+was not. The first: `DecodingHandler`'s empty-body screen (P7-67) reduced from `raise missing_body if
+source.eof?` to a bare probe left every suite green on both rows, because the one empty-body case asserted
+only that the message names `PetWitness`, which the witness's own shape failure over the drained `""`
+names too; through the real codec an empty 200 then read `malformed JSON: unexpected end of input`, naming
+no target. The case now asserts `no body` beside the target and `composition_test.rb` drives an empty 200
+through `Pipeline.standard` and the real codec (guard 23.5; guard 23's row corrected — its third failure
+was the `eof?`-probe case, not the empty body). The second: P7-7's require-time floor assertion was
+exercised by nothing — every gate row runs the bundle's json 3.0.2 — so deleting the block left all six
+adapter suites and every gate green while stock Ruby 4.0's json 2.18.0, which has a `JSON::Coder`, loaded
+and registered. `json/floor_test.rb` now drives it in a child process with `RUBYOPT`, `RUBYLIB` and the
+`BUNDLE_*`/`BUNDLER_*` keys cleared, pinning the interpreter's default json by exact version with `gem`
+before the require (2.6.3 / 2.7.2 / 2.9.1 / 2.18.0 across the matrix, each refused with the `SeamError`
+naming the floor and the active version) and the running json beside it (loads, registers under `:json`);
+its expectation is computed from the entry file's own comparison, so a future Ruby whose default json clears
+the floor keeps it meaningful (guard 35). The nits: the two handler messages rendered an anonymous witness
+as an empty name ("decode into : the response carried none") and now read `an anonymous witness` through a
+private `#target_name` in each — two `lib/` lines, two `sig/` lines, no public surface, P7-70's row amended
+in place (guard 36); and the gate line above, which still carried round 0's run count. The battery is
+thirty-eight, thirty-six caught on both rows, guard 3 on 3.2.11 alone and guard 21 the one equivalent
+mutant. No ledger row is added.
+
+**2026-09-20** — **Phase 7a reconciled onto `main` after phases 7b (server-sent events) and 7c
+(pagination)**, by a rebase-and-reprove pass, which completes phase 7: its three sub-phases were built
+concurrently off `c53638b` and landed 7b, 7c, 7a, so umbrella #25 closes by hand once this stack is
+merged. Phase 7b's stack merged first (#81 `90abdb8` → #82 `eacf165` → #83 `34f52e8`) and 7c's reconciled
+stack after it (`3a1f0a4` → `549e683` → `79877b5`), so 7a's three branches — built off `c53638b` and
+reviewed at `ae15acb` → `04aad28` → `408698e` — were rebased onto 7c's reconciled docs tip `79877b5`,
+whose tree is what `main` holds once those three squashes land, with `git rebase --onto` (rerere
+disabled), every 7a commit preserved and none reordered; the stack is `e5a32ff` → `7a675c4` → this
+paragraph's own commit, the pass's one commit of its own, on the docs branch, carrying what no 7a
+commit could: this paragraph and the dated "Reconciled" note at the head of 7a's checklist. One
+repair to the pass's own work: the conflict stop on the feat commit ran its message through git's
+default comment cleanup, which dropped the three body lines that begin with `#media_type` and
+`#read_utf8`, so that commit was reworded back to its original message verbatim (same tree, same
+author and date) and the stack re-parented over it before any of the tips below were recorded; all
+nine messages now equal the reviewed ones. No test needed a repair and nothing was built. Seven files both sides had changed were reconciled inside
+the rebased 7a commits and nowhere else: `gems/dexpace-core/lib/dexpace.rb` (7b's `# Phase 7b:` block,
+7c's `# Phase 7c:` block, then 7a's `# Phase 7a:` block, each verbatim — 7a's comment still says "after
+6b's block", which stays true with the other two between), `test/fixtures/surface/dexpace-core.txt`
+(the auto-merge was already the regenerated manifest, confirmed by a `surface:regenerate` on the rebased
+code tip that changed nothing: 1,257 rows on the base plus 7a's 73, 1,330 — the same 73 rows 7a's own
+delta added over `c53638b`, all under `Dexpace::Serde` and `Body.serialized`, none removed and no private
+constant among them; `dexpace-serde-json.txt` 2 → 15 as before; the other four manifests unchanged),
+`CLAUDE.md` (re-derived from the combined tree: "… 6c, 7b, 7c and 7a are built" and the whole of phase
+7; two hundred and nineteen `lib/` files beside `version.rb` with two hundred and nineteen `sig/`
+mirrors — 7b's nine, 7c's fifteen and 7a's eleven over phase 6's 184; the same nineteen
+`private_constant` test-mirror exceptions, 7a adding none, every one of its eleven files mirrored on the
+tests branch; seventeen checklists; every merged lane's layer sentence and 7a's in the opening
+paragraph, in merge order; 7b's four, 7c's four and 7a's five "Constraints that will bite" lines;
+eighteen gates everywhere the base says so), `README.md` (both layer sentences and 7a's, the gem table's
+`json >= 2.19.9` row, "the other four are still skeletons"; its built-phases sentence, which 7a's own
+branch had not touched, names 7a beside 7b and 7c), `docs/README.md` (all three layers and all three
+pages, seventeen pages), `docs/sdk-documentation/architecture.md` (the `sse.md` and `serde.md` entries,
+the `write-a-serde.md` placeholder pointing at `serde.md`, and its opening list, which 7a's own branch
+had left at fourteen pages without `serde.md`, re-derived to seventeen), and this roadmap (every status
+note in merge order — 7b's, 7c's, 7c's reconciliation, then 7a's with its two review-round paragraphs;
+the phase-10 inbound list at forty-nine bullets, the base's forty-seven plus 7a's two, each cited by
+date and content). Every file only one lane touched is byte-identical to that lane's tip: 7a's own
+(`docs/first-release.md`, `.claude/skills/knowledge-lookup/SKILL.md`, `docs/knowledge/notes/serde.md`,
+`docs/sdk-documentation/serde.md`, `gems/dexpace-serde-json/README.md`, 7a's checklist before this
+pass's note, its design, the Steepfile's `:serde_json` block, every file under `lib/dexpace/serde/`,
+`gems/dexpace-serde-json/` and the two gems' `sig/` and `test/` trees) to `408698e`, and the merged
+lanes' (`gems/dexpace-core/README.md` among them — 7a's docs branch never touched it, so it names the
+server-sent-events and pagination layers and not the serialization layer, a gap this pass records
+rather than closes) to `79877b5`. 7a's checklist's and status note's count sentences describe its own
+base, `c53638b`, and now say so. Re-proven at every rebased tip: the code tip green on every one of the
+eighteen gates run individually on 4.0.6 (`test:gems` 3,151 runs, 70,100 assertions, 0 failures, 0
+errors, 0 skips, 97.82 % line coverage — above the floor, so no tip in the stack is red) and on the
+3.2.11 matrix row, `gates:serde_boundary` reporting its eight guarded globs clean with `serde/` beside
+them; the tests tip green on the whole default task on 4.0.6 (3,380 runs, 71,339 assertions, 0 skips,
+99.96 % line coverage), on the matrix set on 3.2.11, 3.3.12 and 3.4.10, and on the core suite under a
+second seed on 4.0.6 and 3.2.11 with identical run counts (3,286), the five converted registry pins
+re-run with all three phase-7 layers loaded in one process and `composition_test.rb` by name; the docs
+tip green on the default task, the honest RuboCop run, the probe, the knowledge-structure verifier and
+every `ruby` fence of `serde.md`, `sse.md` and `pagination.md` on 4.0.6 (`serde.md`'s tenth fence run
+with `require "stringio"` prepended, the nit 7a's review recorded).
diff --git a/docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md b/docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md
new file mode 100644
index 0000000..d47f943
--- /dev/null
+++ b/docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md
@@ -0,0 +1,467 @@
+# Phase 7a — Serialization: Checklist
+
+**Written at execution time, 2026-09-20, from what was built** — not from the plan. A row whose task
+did not do what the plan said is a row that says so, and the "Deviations from the plan" section
+below is where each departure is stated with its reason. The design and the plan were written on
+2026-09-10 against the *designs* of phases 4–6 (not their code), on a machine that then had only Ruby
+3.4.10 and json 2.9.1 / 2.19.9, concurrently with 7b's and 7c's documents and before phase 6 was built.
+Since then phases 4a–6c were built, reviewed and merged (#53–#80), every interpreter in the matrix was
+installed, and the bundle resolves json **3.0.2** on every ABI. **This sub-phase was cut from `main` at
+`c53638b`**, which holds every phase through 6b, and was built in parallel with 7b, 7c and 8a on the
+same base — so nothing of theirs exists on this tree and nothing here describes any of it as landed. It
+is the first phase in the roadmap to write into two gems, and the first to spend an adapter's `NFR-2`
+third-party half; the three zero-dependency gates had never seen a gem carrying one. Where the plan's
+text and the built tree disagree the tree wins and this document records it.
+
+**Reconciled 2026-09-20.** Phase 7b's stack merged first (#81 → #82 → #83, `main` at `34f52e8`) and
+7c's reconciled stack after it, so this phase's three branches were rebased onto the tree that holds
+both by `git rebase --onto` with rerere disabled, every 7a commit preserved and none reordered. The
+sentences below that count the tree describe **this phase's own base**, `c53638b`, and are left as
+written; on the combined tree the figures are: 219 `lib/dexpace/` files beside `version.rb` (7b's
+nine, 7c's fifteen and 7a's eleven over the 184 of phase 6), **nineteen** `private_constant`
+test-mirror exceptions (the base's eighteen and 7c's `page/closing.rb`; 7a still adds none — every one
+of its eleven `lib/` files has a `test/` mirror, re-checked by the mirror walk on the rebased tests
+tip), seventeen checklists, seventeen as-built pages, the core manifest 1 257 → 1 330 (still exactly
+this phase's 73 rows, the adapter's 2 → 15 unchanged), the entry file's `# Phase 7a:` block after 7c's
+rather than directly after 6b's (its own comment still says "after 6b's block", which stays true with
+7b's and 7c's between), and **eighteen** gates — 7b's `gates:serde_boundary`, which this phase's base
+did not have and which scans `sse/**` and `page/**` alone, is green with `serde/` beside them. The
+five converted registry pins were re-run with all three phase-7 layers in one process and in the bare
+child; `composition_test.rb` was run by name. No 7a commit needed a repair and nothing was built by
+the pass. The combined tree's counts are `CLAUDE.md`'s and the roadmap's reconciliation note's; this
+document's are its base's.
+
+Legend, verbatim from the roadmap's cross-cutting constraint 3: ✅ implemented and tested ·
+🚫 not built (permanent simplification, named reason) · ⏳ deferred (naming the plan task — phase,
+task number and path — that will do it, or the `docs/first-release.md` entry that owns it) ·
+N/A not applicable in this port.
+
+Plan: `docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization.md`. Task numbers are that plan's
+(nineteen). Design: `docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-design.md`, whose
+Deviation Ledger numbers P7-1–P7-9 and whose as-built rows **P7-61–P7-72** are cited below (7b's and
+7c's design ledgers knowingly share P7-1–P7-n; a phase-7 row is cited with its sub-phase letter, and
+the manager fixed the as-built bands so the three lanes never collide: 7a from P7-61, 7b from P7-81,
+7c from P7-101); the charter is `docs/work/mvp/phase7/2026-09-10-phase7-segmentation-design.md`. Core
+test files are under `gems/dexpace-core/test/`, adapter test files under `gems/dexpace-serde-json/test/`;
+every `lib/` file has a `test/` mirror one for one (three core suites and five adapter suites carry no
+`lib/` mirror and say so below), and each opens with the IDs it exercises.
+
+## Requirement rows
+
+Thirty own rows — `SERDE-1`–`SERDE-30` — plus the cross-reference rows for the non-`SERDE` IDs this
+phase owns a share of or composes, taken from the design's interface table, its out-of-scope table and
+the plan's Task 18. **Thirty ✅**, nothing ⏳, nothing 🚫, nothing N/A; nine rows carry a clause the
+design said the checklist must state rather than tick (`SERDE-6`, `7`, `8`, `11`, `14`, `17`, `26`,
+`27`, `29`) and nine are touched by a deviation row (`SERDE-4`, `9`, `13`, `15`, `19`, `20`, `24`,
+`26`, `27`).
+
+| ID | Level | Status | Task(s) | What was built, and where it is proven |
+|---|---|---|---|---|
+| `SERDE-1` | MUST | ✅ | 13, 14, 16 | One bundle, one reference: `Dexpace::Serde::JSON::Codec` answers all six seam methods and `.conforms?` accepts it; the encoder and decoder round-trip a model through one instance (`json/codec_test.rb` `EncodeProfilesTest`, "SERDE-1: the seam's six methods are all present"; `json/defaults_test.rb`, "SERDE-1: the encoder and decoder round-trip through one bundle") |
+| `SERDE-2` | MUST | ✅ | 9, 14 | `Dexpace::Body.serialized(value, serde:)`, the ninth factory beside 3b's eight: a replayable `BytesBody` over `serde.dump_bytes(value)` whose media type is the serde's declared one — a `MediaType` passed through, a `String` parsed through phase 1's `MediaType.parse` (which refuses a value the header grammar cannot carry, naming it), a nil or empty one refused naming the codec's class with **no format-agnostic fallback**; the codec answers `MediaType.parse("application/json")`, one frozen constant. The header itself is the transport's to stamp when the caller set none (`TRANSPORT-10`, phase 8), asserted on the composed request (`http/body_serialized_test.rb`, all eight cases; `json/codec_test.rb`, "SEAM-19/SERDE-2"; `json/composition_test.rb` `WriteTest`; guard 27) |
+| `SERDE-3` | MUST | ✅ | 14, 15, 17 | A codec closes nothing: `#dump_to` writes and answers the count without closing the sink, `#dump_into` touches only the target region, `#load` reads to EOF (a `BufferedSource` through `#read_utf8`, a raw `#read` IO through a dropped `BufferedSource.wrapping`) and never closes the source — asserted against `CloseCountingSink` / `CloseCountingSource` whose counts read **exactly 0**, under the default and under every accepted option, "even when the codec's own auto-close feature is enabled" (the requirement's tail) meaning every option this adapter accepts (`json/codec_test.rb`, "SERDE-3: dump_to …"; `json/codec_load_test.rb` `StreamTest`, two `SERDE-3` cases; `json/seam_conformance_test.rb`, both cases; guard 15) |
+| `SERDE-4` | MUST | ✅ (P7-5) | 14, 17 | `#dump_into(value, buffer, offset:)` answers the byte count, honours the offset, raises **`::IndexError`** — distinct from `Dexpace::Serde::Error` and `cause: nil` even when called from inside a caller's rescue — on a negative or out-of-range offset and on a payload that does not fit, with an EXPLICIT fit check because `String#[]=` silently grows a String on an over-long payload (verified fact 12), and leaves the bytes before and after the region untouched. The target is a mutable `Encoding::BINARY` String only; a frozen, non-BINARY or non-String buffer and a non-Integer offset are `InvalidArgumentError`, and Ruby's `IO::Buffer` is refused (P7-5: it warns through `Warning.warn` on construction and its `#set_string` raises `ArgumentError` where `String#[]=` raises `IndexError`); the adapter's suite asserts over its own source that no Ruby `IO::Buffer` is constructed (`json/codec_test.rb` `EncodeProfilesTest`, the four `SERDE-4` cases and the two `P7-5` cases; `SerdeSeamAssertions#assert_buffer_profile`; guards 16–18) |
+| `SERDE-5` | MUST | ✅ | 3, 14, 15 | Every decode takes an explicit witness: `Codec#load(source, witness)` has no witness-less overload (`ArgumentError` with one argument), runs `Dexpace::Serde.witness!` on the second, and the result is the REAL type with typed field access; a `#call`-shaped object is not a witness (`json/codec_load_test.rb` `StreamTest`, "SERDE-5: decode through an explicit witness yields the real type", "there is no witness-less overload"; `serde/witness_test.rb`, the lambda case; guard 14) |
+| `SERDE-6` | MUST | ✅ (clause stated) | 5 | Parametric targets are expressible as combinators built BY VALUE from a concrete element witness — `List.of(Pet)`, `Map.of(String, Pet)`, `Nullable.of(Pet)`, nesting freely, decoding to real DTOs, over the scalar spellings `String` / `Integer` / `Float` / `BOOLEAN`. **The clause stated:** the requirement's second half — "a format-agnostic decoder that cannot resolve type arguments MUST fail loudly … rather than silently decoding into the raw type" — is unreachable, not implemented: there is no witness-less overload to fall into and a combinator cannot exist without a concrete element, so no decoder ever holds a partially-resolved carrier (design §10.14) (`serde/list_test.rb`, `map_test.rb`, `nullable_test.rb`, `scalars_test.rb`, all cases) |
+| `SERDE-7` | MUST | ✅ (clause stated) | 3 | Its antecedent is conditional — "(where the host language offers one)" — and Ruby offers no reified generics; the port satisfies it anyway in the strongest form available: the ergonomic route `serde.load(source, Pet)` passes the class object itself, and that route IS the carrier, so there is no raw-class shortcut beside it to forget to route through (`serde/witness_test.rb`, "the ergonomic spelling and the carrier spelling are the same object") |
+| `SERDE-8` | MUST | ✅ (clause stated) | 3, 5, 6 | The no-type-argument half is implemented: `List.of(nil)`, `List.of(Object.new)`, `List.of(::Symbol)`, `Map.of(String, nil)`, `Nullable.of(nil)` and `Tristate.of(Object.new)` all raise `InvalidArgumentError` at CONSTRUCTION with an actionable message naming the missing method and the class — earlier than the reference's binder-resolution failure. **The clause stated:** the "unresolved type variable" half is unreachable, and that is §10.14's strength rather than a gap — a combinator cannot be built without a concrete element witness (`serde/list_test.rb`, "SERDE-8"; `map_test.rb`; `nullable_test.rb`; `tristate_decode_test.rb`; `witness_test.rb`; guard 13) |
+| `SERDE-9` | MUST | ✅ (P7-6) | 14, 15, 17 | The write path raises `Dexpace::Serde::SerializationError` — core's `Native` refusing a non-native value NAMING ITS CLASS before the generator sees it, and the generator's own `GeneratorError` (a `NaN`, a BINARY String holding invalid UTF-8) rescued as `::JSON::JSONError` and re-raised INSIDE the rescue so Ruby chains it as `#cause`; the read path raises `DeserializationError` chaining `ParserError` / `NestingError`; and no `::JSON` type escapes the SPI. P7-6 adds the port's own read-side guard: invalid UTF-8 in the drained text is a `DeserializationError` with no cause, before the parser. A duplicate key is one `DeserializationError` across json 2.19.9–3.0 because the codec fixes `allow_duplicate_key: false` (P7-65) — proven at the keyword level since review round 0, because json 3.0.2 refuses a duplicate key by default and the behavioural case alone could not tell the codec's option from the library's (`json/codec_test.rb` `FailureModelTest`, `ConstructionTest`'s duplicate-key case, `CoderKeywordsTest`; `json/codec_load_test.rb`, "SERDE-13/SERDE-9", "SERDE-9: a nesting-depth failure", both `P7-6` cases; `serde/native_test.rb`; guards 20, 30, 34, 34.5) |
+| `SERDE-10` | MUST | ✅ | 14, 17 | `SerializationError` and `DeserializationError` are two subtypes under phase 2's `Dexpace::Serde::Error` root, neither a kind of the other, so a caller distinguishes direction while catching one base (`json/codec_test.rb` `FailureModelTest`, "SERDE-10: the write-path subtype is distinct"; `SerdeSeamAssertions#assert_failure_model`) |
+| `SERDE-11` | SHOULD | ✅ (clause stated) | — | Satisfied by the language and needing no code (design §11.15): every serde error is a `::StandardError` descendant and Ruby has no checked exceptions; asserted as ancestry beside the malformed-input case (`json/codec_load_test.rb`, "SERDE-13/SERDE-9", `assert_kind_of(::StandardError, error)`) |
+| `SERDE-12` | MUST | ✅ | 10, 15, 17 | A genuine stream I/O error propagates UNWRAPPED, structurally: `Codec#load` rescues `::JSON::JSONError` around the parse ALONE, whose ancestry is `[ParserError, JSONError, StandardError]` with `IOError` nowhere in it (verified fact 4), so a `Dexpace::StreamError` (an `::IOError`) from the source, a raw IO's own `IOError`, and the over-ceiling `StreamError` all pass through the codec and the handler's `ensure`-close untouched; a raising sink's `IOError` too (`json/codec_load_test.rb` `StreamTest`, three cases; `serde/decoding_handler_test.rb` `MatrixTest`, two cases; `SerdeSeamAssertions#assert_io_error_passthrough`; guard 21.5 red, guard 21 the recorded equivalent) |
+| `SERDE-13` | MUST | ✅ (P7-6) | 2, 15 | A wire null into a non-null target fails NAMING THE TARGET TYPE, across every decode overload — which is one method: `Codec#load` builds `DecodeContext.root(target: witness)` and `#error!` renders the ROOT frame as `expected Pet (Hash) at /, got NilClass` (a nested frame keeps the plain form, and a same-named `#present!` target is not doubled); the context, not a nil screen in `#load`, because `Tristate.of` and `Nullable.of` legitimately take a top-level null (`serde/decode_context_test.rb` `RaiseSiteTest`, all seven cases; `json/codec_load_test.rb` `ShapeTest`, "SERDE-13" and "SERDE-20"; guards 11, 12) |
+| `SERDE-14` | MUST | ✅ (clause stated) | 4 | The substantive half is real code, closed on EVERY path: `Present` validates in `#initialize` with `Model.required!("value", value)` (`SEAM-29`'s message), `.new` AND `.[]` are private so `Present[value: nil]` is a `NoMethodError` and the `send`-past-private hole meets the same check, and `Model#with` routes a derivation through `.build` so `T.present(1).with(value: nil)` raises on every supported Ruby — on 3.2.11 the guard that substitutes `Data#with` goes red where 4.0.6 stays green, which is why `test:gems` on the floor is the proof. **The clause stated:** the covariance clause is satisfied by the language (design §11.15) and has no code (`serde/tristate_test.rb`, the three `SERDE-14` cases; guards 1–3) |
+| `SERDE-15` | MUST | ✅ (P7-9) | 7, 16 | In an object, Absent omits the key, Null emits a wire null and Present emits the encoded inner value — in core's `Native` walk, which drops a Hash entry whose walked value is `OMIT` (`ABSENT#dexpace_dump`) and re-walks a Present's inner value so a nested model's Absent is dropped too; through the real codec, `{"name":"x"}` / `{"name":"x","nick":null}` / `{"name":"x","nick":"n"}`; and over a seeded property sample of tri-state models that round-trip exactly (`serde/native_test.rb`, both `SERDE-15` cases; `json/defaults_test.rb`, "SERDE-19", "SERDE-15/SERDE-16: a seeded sample"; guard 4) |
+| `SERDE-16` | MUST | ✅ | 6, 16 | `Tristate.of(w).dexpace_load_field(hash, key, ctx)`: a missing key is Absent, an explicit null is Null, a value is Present of the element's decode at the key's own path with the declared element type preserved (`Float` widened, a mis-shaped value refused naming `/pet/x`) — three lines over `Hash#key?` (`serde/tristate_decode_test.rb`, "SERDE-16/SERDE-17", "SERDE-16: the inner value's declared element type", "the field's shape failure names the field's own path"; `json/defaults_test.rb`, "SERDE-16/SERDE-17"; guard 7) |
+| `SERDE-17` | MUST | ✅ (clause stated) | 6 | An omitted field is Absent and NEVER Null, asserted as both predicates. **The clause stated:** the field-default machinery the requirement describes ("the field's declared default must be Absent … before the decoder's null hook runs") is a key-oriented codec's problem Ruby does not have — `JSON.parse` yields an ordinary Hash and `key?` distinguishes the two directly (verified fact 6) — so the behaviour its conformance clause names is implemented and none of the machinery is emulated (`serde/tristate_decode_test.rb`, "SERDE-17: an omitted field is Absent and never Null"; guard 7) |
+| `SERDE-18` | SHOULD | ✅ | 4 | `Tristate.absent`, `.null`, `.present(value)` (refusing nil), `.from_nullable(value)` (Present or Null, never Absent — a different name from `.of`, deliberately), the three exhaustive and mutually exclusive predicates, `#value_or_nil` and the three-way `#fold` (`serde/tristate_test.rb`, the five `SERDE-18` cases; guard 8) |
+| `SERDE-19` | MUST | ✅ (P7-9) | 7, 16 | The DEFAULT configuration wires tri-state with nothing registered: `Codec.default.dump_string` of a model with an Absent field omits the key and with a Null field emits null, because the wiring is core's `Native` walk and structural rather than a per-model convention; an adapter building a serde around a caller-supplied codec instance never happens (P7-4), so the register-by-default and opt-out clauses have no subject (`json/defaults_test.rb`, "SERDE-19: the DEFAULT configuration wires tri-state"; `serde/tristate_test.rb`, "#dexpace_dump"; guard 32) |
+| `SERDE-20` | SHOULD | ✅ (P7-9) | 6, 7, 16 | Where no enclosing object can omit a key, both Absent and Null emit a wire null rather than throwing: an Array element that walks to `OMIT` is `nil` with its position kept, a top-level `OMIT` is `nil`, and through the real codec `"null"` / `"[null,null,\"v\"]"`; the decode half — a top-level null is Null through `Tristate.of`'s protocol entry point and nil through `Nullable.of` (`serde/native_test.rb`, both `SERDE-20` cases; `json/defaults_test.rb`, "SERDE-20"; `serde/tristate_decode_test.rb`, "SERDE-20"; `json/codec_load_test.rb`, "SERDE-20"; guards 5, 6) |
+| `SERDE-21` | MUST | ✅ | 2, 15 | The nine named cross-shape coercions are nine explicit fixtures on `DecodeContext`, never a loop — string→integer, string→float, string→boolean, empty-string→integer/float/boolean, float→integer (`1.5` and `1.0`), boolean→integer and integer→boolean (`1` and `0`), boolean→float, non-string-scalar→string — each a `DeserializationError`; and through the real codec, which coerces nothing so the witness sees the wire shape (`serde/decode_context_test.rb` `CoercionTest`, the eight `SERDE-21` cases; `json/codec_load_test.rb` `ShapeTest`, "SERDE-21"; `serde/scalars_test.rb`; guard 9) |
+| `SERDE-22` | MUST | ✅ | 2, 15 | The strict policy still permits the representation-preserving conversions: `#float!(1)` widens to `1.0`, `#string!("")` binds, and every well-typed value binds unchanged — through the context, through `List.of(Float)` over `[1]`, and through the real codec (`serde/decode_context_test.rb` `CoercionTest`, the three `SERDE-22` cases; `serde/list_test.rb`; `json/codec_load_test.rb`, "SERDE-22"; guard 10) |
+| `SERDE-23` | SHOULD | ✅ | 5, 15 | An unknown field is ignored rather than failing, as the witness protocol's default — a witness reads the keys it declares and never enumerates the object, and core ships no strictness flag; asserted on a DTO and through the real codec (`serde/list_test.rb` through `Pet`; `json/codec_load_test.rb` `ShapeTest`, "SERDE-23"; `json/composition_test.rb`, the `"extra":true` payload) |
+| `SERDE-24` | SHOULD | ✅ (P7-8) | 8, 16 | ISO-8601 strings, never epoch numbers: core's `Instant` renders `#iso8601(6)` and parses through `Time.iso8601` (refusing the lax forms and a non-String, naming `Time (ISO-8601)` at the path), and the adapter's default `encoders:` table wires `::Time`, `::DateTime` and `::Date` to it. The round trip holds exactly over the stated domain — any `Time` whose `subsec` is an exact multiple of one microsecond, proven over whole seconds, an exact-microsecond `Time`, a UTC offset and a 64-sample seeded property test — and P7-8's truncation outside it is asserted, not prose: `Time.new(2026,9,10,12,0,0.123456,"+02:00")` renders `…00.123455+02:00` and does not round-trip (`serde/instant_test.rb`, all eleven cases; `json/defaults_test.rb`, the four `SERDE-24` / `P7-8` cases and the Date/DateTime case; guards 31, 31.5) |
+| `SERDE-25` | SHOULD | ✅ | 13, 14 | `Dexpace::Serde::JSON.default` and `Codec.default` are factories answering a fresh, independent instance on every call, and the registry's factory is `.default` itself; the memoising mutation goes red (`json/codec_test.rb` `ConstructionTest`, "SERDE-25"; `json_test.rb`, "the module's two factories"; guard 28) |
+| `SERDE-26` | MUST | ✅ (P7-4; clause stated) | 14 | Satisfied literally and not through §11.18's fallback: the constructor takes OPTIONS, never a caller's coder — so "built around a caller-supplied codec instance" never happens — and each instance owns a private `::JSON::Coder` built from its own options (json 2.19.9's per-instance, freezable engine), with no reader; two codecs share no engine, and one built with `max_nesting: 4` reads the same five-deep document differently from the default on both the decode and the encode side. **The clause stated:** the antecedent is false by construction (`json/codec_test.rb` `ConstructionTest`, the two `SERDE-26` cases; guard 29) |
+| `SERDE-27` | MUST | ✅ (P7-1; clause stated) | 10 | `Dexpace::Serde::DecodingHandler.build(serde:, witness:)`, a `_ResponseHandler` supplied into 3b's `TypedResponse`: it hands `#load` the body's own `#source` and copies nothing; closes the response in one unguarded `ensure` on EVERY path (a valid body, a missing body, an empty body, a codec failure, a mid-stream I/O error, a failing `eof?` probe) with the count read as exactly 1 off `FakeResponseBody`'s raw counter; surfaces a nil body AND an empty one — screened with `BufferedSource#eof?`, a non-consuming probe — as a `DeserializationError` naming the target (or `an anonymous witness` for a `Class.new` one, which has no name to carry — P7-70, review round 1's R1-4); and rescues nothing, so the codec's chained failure and an unwrapped `StreamError` both pass through. **The clause stated:** "without first materializing the whole body" is NOT satisfied (P7-1): the JSON adapter drains to EOF under `Dexpace::IO.max_materialized_bytes` and a body above it raises `Dexpace::StreamError`, unwrapped — the documented limit `docs/sdk-documentation/serde.md` now states, closing the first owed half of the `docs/first-release.md` entry; the second half waits on phase 8. A `BytesBody`-backed response raises `StreamError` naming the class and `Body.buffer` is the readable spelling, asserted as a contract (`serde/decoding_handler_test.rb`, all fifteen cases — the empty-body case asserting BOTH halves of the message since review round 1, because the witness's own shape failure over a drained `""` names the target too; `json/composition_test.rb`, an empty 200 through the real codec; guards 22, 23, 23.5) |
+| `SERDE-28` | MUST | ✅ | 11, 18 | `Dexpace::Serde::StatusAwareHandler.build(serde:, witness:, factory:)`: a 2xx delegates to a `DecodingHandler` (one implementation of `SERDE-27`); 400, 404, 500 and the **non-canonical 599** raise the factory's error — `ProtocolError.for` by default, one frozen lambda, `raise error, cause: nil` — over `Recovery.buffer_error_body`'s bounded copy, readable twice after the live response closed, with NO second close (the raw counter reads 1) and the error payload never reaching the witness; a 304 with `ETag` and `Location`, a malformed `ETag`, a multi-valued `Location`, a 100, a 301 and a 307 close the response (a close failure propagates) and raise a `DeserializationError` leading with the code and carrying the raw header values, parsed by nothing; a factory returning a non-Exception is refused. The composed path — `Operation` → `Pipeline.standard` → `TypedResponse` — asserts the 200, 404, factory and 304 branches end to end (`serde/status_aware_handler_test.rb`, all nineteen cases — a 304 through an anonymous witness reads `an anonymous witness`, never an empty name, since review round 1; `json/composition_test.rb`; guards 24–26, 36) |
+| `SERDE-29` | SHOULD | ✅ (clause stated) | 14, 17 | A frozen codec is safe to share: eight threads × 200 rounds encoding and decoding distinct values through one instance, every thread joined, no corruption; and through the lift target. **The clause stated:** the cache clause has no subject — the witness is supplied per call and nothing is memoised by type, asserted as no `cache`/`memo` ivar on the codec, which is what a phase-9 `XCUT-12` audit will look for here (`json/codec_test.rb` `ConstructionTest`, both `SERDE-29` cases; `SerdeSeamAssertions#assert_shareable`) |
+| `SERDE-30` | MAY | ✅ (taken) | 4, 7 | `ABSENT`, `NULL` and `OMIT` print as `"Absent"`, `"Null"` and `"Omit"` for both `#to_s` and `#inspect`, asserted as string equality; the three are frozen singletons of classes a caller cannot name (`serde/tristate_test.rb`, "SERDE-30"; `serde/native_test.rb`, "OMIT is a frozen sentinel") |
+
+Cross-reference rows, the IDs this phase owns a share of or composes — each "composition asserted,
+not satisfied here" where an earlier phase satisfies it:
+
+| ID | Status | What 7a supplies, and where it is proven |
+|---|---|---|
+| `SEAM-19`, `SEAM-20`, `SEAM-21` | ✅ implemented against | Phase 2's six-method seam is implemented, not redesigned: `#media_type` a `MediaType`, the four encode profiles, `#load` reading to EOF and closing nothing; `interface _Codec` edited in place — `#media_type` to `(Dexpace::MediaType \| String)`, `#load` over `_Witness`, `#dump_to` to `Integer` — never a second interface (P7-61) (`json/codec_test.rb`; `json/codec_load_test.rb`; `sig/dexpace/serde.rbs`) |
+| `SEAM-22` | ✅ surviving clause honoured | The witness protocol §10.14 substituted is built; the surviving clause — `#load` takes an explicit witness, no witness-less overload — holds on the real codec (`json/codec_load_test.rb`, "there is no witness-less overload"). The ID's row is phase 2's and does not move |
+| `SEAM-23` | ✅ consumed | Both subtypes raised from the adapter under phase 2's class root; no second root (`json/codec_test.rb` `FailureModelTest`) |
+| `SEAM-2` | ✅ mechanised | `Dexpace::Serde::JSON` appears in no core `lib/`, `sig/` or `test/` file outside a comment — a Ripper-based scan, because two core comments already name the adapter (P7-68) — and the seam supplies no media type; the adapter's registration is asserted only in the adapter's suite, and core's "starts empty on a bare require" pins now assert in a child process (`serde/no_concrete_codec_test.rb`; `seam_surface_test.rb`; `serde_test.rb`) |
+| `SEAM-26`, `SEAM-27` | ✅ composition asserted | Phase 2 satisfies; Task 18 asserts the composition: `Operation.build(method:, template:, projections:)` → `#build_request` → `Pipeline.standard` over a recording lambda transport → `TypedResponse`, with `a/b` reaching the wire as one `a%2Fb` segment and a `PATCH` body carried as a `Dexpace::Body` (`json/composition_test.rb` `ReadTest`, `WriteTest`) |
+| `HTTP-44`, `HTTP-45` | ✅ consumed | 3b's `TypedResponse` is supplied into, never replaced: the handler runs once across three `#value` calls, a memoised failure is re-raised as the SAME object with its cause, and no second memo, `@state` or lock exists in 7a (`serde/decoding_handler_test.rb` `ConstructionTest`; `serde/status_aware_handler_test.rb` `MappedTest`, "TypedResponse takes it") |
+| `HTTP-41`, `HTTP-42`, `BODY-14`, `BODY-16` | ✅ consumed | `#source` is THE read handle (no `respond_to?` fallback; a `BytesBody` raises by design) and `Response#body_string` reads the buffered error copy twice (`serde/decoding_handler_test.rb`, the `BytesBody` case; `serde/status_aware_handler_test.rb`, "readable twice") |
+| `BODY-30`, `HTTP-52`, `RECOV-16` | ✅ composition asserted | `Recovery.buffer_error_body` is the ONE buffering call site and closes the live body itself; the 4xx branch adds no second buffering and no second close, and the copy is readable repeatably after the walk (`serde/status_aware_handler_test.rb`, "adds no second close"; `json/composition_test.rb`, the 404 case) |
+| `RECOV-15` | ✅ composition asserted | The `factory:` keyword is `ErrorMappingStep`'s spelling — one frozen lambda over `ProtocolError.for` as the default, `Registry.callable?` at arity 1 — and a generated SDK's factory decodes the buffered body into its own type through it (`json/composition_test.rb`, "the factory keyword lets a generated SDK decode the error body") |
+| `IO-9`, `BODY-32` | ✅ consumed | `#load` drains through 3a's `#read_utf8`, guarded by `Dexpace::IO.max_materialized_bytes` (5a's per-call reader) — one ceiling, cited and never re-derived; the over-ceiling `StreamError` propagates unwrapped (`json/codec_load_test.rb`, "R1/P7-1") |
+| `IO-6`, `BODY-8` | ✅ third rule beside them | A codec closes nothing (`SERDE-3`) — the third ownership rule, which phase 3 left to this layer (`message-bodies/a7afc6ee`) — sits beside 3a's wrapping-takes-ownership and 3b's closes-what-it-opened, and the handler's response close is the layer above both (`json/codec_load_test.rb`; `serde/decoding_handler_test.rb`, "the handler closes the response and hands the codec the body's own source, unclosed") |
+| `HTTP-3`, `HTTP-4`, `SEAM-29` | ✅ | Every public `Data` follows the construction pattern: `DecodeContext`, `Present`, `List`, `Map`, `Nullable`, `DecodingHandler` and `StatusAwareHandler` have `.new` and `.[]` private, a validating `.build` over ALL members, validation in `#initialize`, `Model.required!`'s one message form, and `#with` through `.build` (P7-66; every suite's construction case) |
+| `XCUT-12` | ✅ by construction | No mutex in the phase; the codec is frozen after construction and holds no per-type cache (the `SERDE-29` row) |
+| `XCUT-15` | ✅ by construction | `DecodeContext#path` through `Model.own`, `Native` returning fresh collections and copying a mutable String, the combinators frozen `Data`s (`serde/decode_context_test.rb`, "#path is the model's own frozen copy"; `serde/native_test.rb`, "never aliases the caller's") |
+| `TRANSPORT-10` | ✅ boundary stated | `Body.serialized` stamps no header; the media type is the body's and the transport writes it when the caller set none — asserted as the header's absence on the composed request (`json/composition_test.rb` `WriteTest`) |
+| `NFR-1`, `NFR-2` | ✅ spent | `dexpace-serde-json` declares `dexpace-core` plus exactly one third-party gem, `json >= 2.19.9`, the one place that floor is stated; `gates:gemspec_audit` accepts it and refuses a second (guard 33a); core's gemspec still has zero `add_dependency` lines and core's five stdlib requires are unchanged (`json_test.rb`, "NFR-2", "REQUIRED_CORE") |
+| `NFR-3` | ✅ | Eleven core mirrors, two adapter mirrors and phase 2's `serde.rbs` edited in place; `rbs:validate` and the strict `core` Steep target green with no relaxation; the `:serde_json` target alone downgrades `Ruby::UnknownConstant` to `:information` for the one `::JSON::Coder` reference rbs 4.2.0 does not declare (P7-62) |
+| `NFR-4` | ✅ | Every addition is a widening; the manifests grew by exactly 86 rows — core 1 137 → 1 210, the adapter 2 → 15 — regenerated once and read row by row against the object model with no private constant among them; the RBS baseline diff is vacuous until the first tag |
+| `NFR-11` | ✅ | No constant outside `Dexpace::` and the stdlib allowlist in any public signature: core's serde signatures name `::Time` alone, the adapter's name no `::JSON` type (the engine ivar is `untyped`); a `::JSON::State` return in a public signature is refused by `gates:rbs_surface` (guard 33d) and a `::JSON::Coder` ivar type by `rbs:validate` (guard 33c) |
+| `NFR-13` | ✅ for `.rb`; the `.rbs` half is phase 10's | The thirty-four new `.rb` files — eleven under core's `lib/`, one under the adapter's, nineteen suites and three doubles — open with the two headers the `Dexpace/SpdxHeader` cop gates; the twelve new `.rbs` files carry no SPDX line, as no `.rbs` in the repository does (phase 10's `gates:spdx_rbs`), as 6a's and 6b's rows record |
+
+## What was built
+
+Eleven new `lib/` files under `gems/dexpace-core/lib/dexpace/serde/`, in the entry file's dependency
+order: `decode_context.rb`, `witness.rb` (reopening phase 2's `Dexpace::Serde` the way `closeable.rb`
+defines `Dexpace.close_quietly`; `serde.rb`'s body is untouched), `native.rb` (`Native`, and the `OMIT`
+sentinel over the private `Omit` class), `scalars.rb` (the private `Scalars` module with its `Scalar`
+class and `TABLE`, and the public `BOOLEAN`), `tristate.rb` (`Tristate`, `ABSENT`, `NULL`, `Present`, the
+private `Combinator` behind `.of`), `list.rb`, `map.rb`, `nullable.rb`, `instant.rb`,
+`decoding_handler.rb` and `status_aware_handler.rb`. One core file widened in place, 3b's `http/body.rb`
+(`Body.serialized`, plus two `require_relative`s), and one phase-2 `sig/` file edited in place,
+`sig/dexpace/serde.rbs` (`interface _Codec`'s three clauses; P7-61). Every new file has a `sig/` mirror
+— every nested `private_constant` declared there with the tree's "no visibility in RBS" comment —
+and a `test/` mirror, with three core suites beside the mirrors that say so in their headers:
+`serde/tristate_decode_test.rb` (the combinator's decode half), `http/body_serialized_test.rb` (7a's one
+addition to 3b's module) and `serde/no_concrete_codec_test.rb` (the `SEAM-2` scan). No new core
+`private_constant` FILE: the layer's private constants (`EMPTY_PATH`, `ROOT`, `Omit`, `NO_ENCODERS`,
+`Scalars`, `Absent`, `Null`, `Combinator`, `EXPECTED`, `DEFAULT_FACTORY`) all live inside public files,
+so `CLAUDE.md`'s eighteen test-mirror exceptions are unchanged. The entry file gains an eleven-line
+`# Phase 7a:` block after 6b's. **In `gems/dexpace-serde-json`:** the gemspec's `json >= 2.19.9` line;
+the entry file rewritten (`require "json"`, `require "dexpace"`, `MINIMUM_JSON_VERSION`, `REQUIRED_CORE`,
+the top-level floor assertion raising `Dexpace::SeamError`, `.default`, `.build`, the registration under
+`:json`); the new `lib/dexpace/serde/json/codec.rb` (`Codec` with its private `Options` module,
+`DEFAULT_ENCODERS` and `MEDIA_TYPE`) with its `sig/` mirror and the entry file's `sig/` rewritten; the
+smoke test `json_test.rb` reshaped to snapshot AFTER `require "dexpace"`, `json`, `time` and `date` and
+extended with five cases; six new suites beside it — `json/codec_test.rb` (the codec's mirror; four nested
+classes, the fourth review round 0's keyword pin),
+`json/codec_load_test.rb` (two), `json/defaults_test.rb`, `json/seam_conformance_test.rb`,
+`json/composition_test.rb` (two) and, since review round 1, `json/floor_test.rb`, which drives P7-7's
+require-time floor assertion in a child process with the bundler environment stripped (R1-2) — and three
+new `test/support/` files, `close_counting_source.rb`,
+`close_counting_sink.rb` and `serde_seam_assertions.rb`, the last the file phase 9 lifts into
+`dexpace-conformance`. The `Steepfile`'s `:serde_json` block gains its one relaxation and its comment;
+`rbs_collection.yaml` is as `main` has it — its stale "json arrives with the codec in phase 7" sentence
+is routed, not rewritten, because the file is a shared one outside this phase's bounds (review round
+0, R0-1; Findings routed). **Five
+existing core tests changed on the code branch as pins the registration invalidated**:
+`seam_surface_test.rb`'s two seam-iterating pins ("starts empty on a bare require", "no seam's
+zero-candidate error names a concrete gem") now run a child process that requires `dexpace` alone and
+prints one row per seam — the `IO.popen([RbConfig.ruby, "-w", "-Ilib", "-e", PROGRAM], err: %i[child
+out], chdir:)` shape of `instrumentation/independence_test.rb` — and `serde_test.rb`'s "starts empty"
+pin the same way, while its two swap pins assert the swapped-in codec is no longer what resolves
+(`refute_same` over a `resolved_after_swap` helper), because `Registry#swap` restores `resolved` and
+never `factories`; 8a is making the identical conversion of the same two `seam_surface_test.rb` lines,
+and the reconcile pass keeps one copy. The surface manifests were regenerated once, with all 86 rows
+read against the object model. The dexpace_test.rb `LAYERS` table is untouched: 7a adds no flat
+constant under `Dexpace`. **Review round 1 changed two `lib/` lines** (R1-4): `DecodingHandler#missing_body`
+and `StatusAwareHandler#unhandled_message` derive the target's name through a private `#target_name`
+that falls back to the literal `"an anonymous witness"` where `DecodeContext.root` gives an anonymous
+class no target (P7-70), declared in both `sig/` mirrors; a named witness's messages are byte-for-byte
+what they were.
+
+## Matrix facts, re-run on every interpreter
+
+The design's fifteen facts and the ones the build found were re-run on 2026-09-20 on **3.2.11, 3.3.12,
+3.4.10 and 4.0.6**, against json **3.0.2** (the bundle's on every ABI, and installed in the 3.2.11 and
+4.0.6 gem directories) and json **2.19.9** (the floor, pinned by `gem "json", "2.19.9"` in an unbundled
+subprocess on 3.4.10). Uniform across the range and the two versions: `JSON.parse(StringIO)` raises
+`TypeError` (fact 1); `JSON.generate(Object.new)` returns the inspect string and `strict: true` raises
+(fact 3); `JSON::ParserError < JSONError < StandardError` with `IOError` nowhere in it (fact 4);
+`JSON.parse` coerces nothing, `key?` distinguishes null from absent, `JSON.parse("null")` is `nil`
+(fact 6); a `"\xff"` payload yields a UTF-8-tagged String whose `#valid_encoding?` is false (fact 7);
+`JSON::Coder` exists, is freezable, and is **strict on its own account** — `Coder.new.dump(Object.new)`
+and `.dump(Time.at(0))` raise `GeneratorError` with no `strict:` at all (fact 8, sharpened);
+`Time#iso8601(6)` truncates `0.123456` to `…123455` and an integer-microsecond `Time` round-trips
+(fact 9); `Time.iso8601` rejects the lax forms; `String#[]=` grows the target (fact 12); `Data#with`
+skips an `initialize` override on 3.2.11 and runs it on 3.3+ (fact 14, the reason `Model#with` is
+load-bearing); `Data.define`'s `.[]` stays public under `private_class_method :new` alone (fact 15).
+**Version-sensitive, and the reason the option allowlist exists** (`docs/knowledge/notes/serde.md`):
+`JSON::Coder.new`'s parameters are `[[:opt, :options], [:block, :as_json]]` on 2.19.9 and keywords with
+a `**options` rest on 3.0.2; an unknown option is swallowed on 2.19.9 and refused with `ArgumentError`
+on 3.0.2; `encoders:` is accepted on 2.19.9 and refused on 3.0.2; `Coder.new(nil)` works on 2.19.9 and
+raises on 3.0.2; a duplicate key is last-wins on 2.9.1, a `warning:` on 2.19.9 under `-w` (which the
+suite's fatal-warnings base turns into an error) and a `ParserError` on 3.0.2, while
+`allow_duplicate_key: false` raises `ParserError` on both. **The interpreters' installed json**:
+3.2.11 and 4.0.6 hold 3.0.2 beside their stock 2.6.3 / 2.18.0; 3.3.12 and 3.4.10 held only their stock
+2.7.2 / 2.9.1 — neither with `JSON::Coder` — until `gates:clean_bundle` fetched 3.0.2 from rubygems.org
+into each on this run (the Findings routed section). `BufferedSource#eof?` is a non-consuming probe,
+true on an empty source and false otherwise, and `#read` with no length answers `""` at EOF, never nil.
+The one thing that differs in a MESSAGE across the versions — `JSON.parse("")`'s text — is matched
+nowhere.
+
+## Guards run red
+
+Every guard the brief lists was seen red, on 4.0.6 and on 3.2.11, and the bytes restored after each:
+thirty-four single-edit mutations of `lib/` (the brief's thirty-two plus two the build added, 21.5 and
+31.5), one at a time through a scratch harness that applies the edit, runs the owning suites under
+`ruby -w`, captures the first failure and restores the file with `git checkout --`; plus the four gate
+mutations of guard 33 on 4.0.6. On the first pass **thirty-one of thirty-four were caught on both rows;
+guard 3 on 3.2.11 alone, as the brief predicted; and two were equivalent mutants, recorded with their
+reasons rather than hidden** — 19, because `JSON::Coder` is strict on its own account, and 21, because
+the parse rescue's scope is the parse alone. Guard 18's assertion is not vacuous: the seam assertion
+calls `#dump_into` from inside a `rescue`, and dropping `cause: nil` turns it red. **Review round 0
+(2026-09-20) ran forty-two of its own and found one more surviving on both rows** — the explicit
+`allow_duplicate_key: false` default dropped from `Codec#initialize`, invisible on the bundle's json
+3.0.2, where a duplicate key is a `ParserError` by default, and a warning-plus-last-wins only at the
+2.19.9 floor, which no gate row runs — so the option is now pinned at the keyword level, in a child
+process that records what `::JSON::Coder.new` receives (`codec_test.rb` `CoderKeywordsTest`), and the
+battery is **thirty-five, thirty-three caught on both rows, guard 3 on 3.2.11 alone and guard 21 the
+one equivalent mutant**: guard 34 is the round's, red on 4.0.6 with json 3.0.2 and on 3.4.10 with json
+2.19.9 pinned unbundled, and guard 19, an equivalent mutant behaviourally, is red at the keyword level
+through the same pin (34.5 below is the third thing the pin holds). **Review round 1 (2026-09-20) ran
+fifty-nine of its own and found two more surviving on both rows**, each against a behaviour the design
+states and this document claimed pinned: the `eof?` screen's raise reduced to a bare probe (23.5 below —
+the empty-body case asserted only `/PetWitness/`, which the witness's own shape failure over the drained
+`""` names too, so the case was green with the screen gone and the round found guard 23's third failure
+to be the `eof?`-probe case, not it), and P7-7's require-time floor block deleted outright (35 below —
+no gate row runs a json below the floor, so nothing saw it). Both are pinned on the tests branch, the
+second in a child process with the bundler environment stripped; the round's one nit that reached
+`lib/`, the anonymous-witness fallback, is guard 36. The battery is **thirty-eight, thirty-six caught
+on both rows, guard 3 on 3.2.11 alone and guard 21 the one equivalent mutant**.
+
+| # | Fix reverted | Guard | What it said (4.0.6; identical on 3.2.11 unless stated) |
+|---|---|---|---|
+| 1 | `SERDE-14`: `Model.required!` dropped from `Present#initialize` | `tristate_test.rb` | `Dexpace::InvalidArgumentError expected but nothing was raised` (3 failures) |
+| 2 | `SERDE-14`: `.[]` left public, validation in `.build` alone | `tristate_test.rb` | `Expected Dexpace::Serde::Tristate::Present to not respond to []` |
+| 3 | `SERDE-14`: `Data#with` bound in place of `Model#with` | `tristate_test.rb` | **3.2.11: RED** — `Dexpace::InvalidArgumentError expected but nothing was raised` on "#with cannot derive a Present holding nil"; **4.0.6: GREEN**, because `Data#with` runs the `initialize` override there. Interpreter-sensitive as predicted, and why `test:gems` on the floor is the proof |
+| 4 | `SERDE-15`: the Hash branch stores `nil` instead of dropping an `OMIT` | `native_test.rb`, `defaults_test.rb` | `--- expected {"name" => "x"} +++ actual {"name" => "x", "nick" => nil}` (6 failures) |
+| 5 | `SERDE-20`: the Array branch drops an `OMIT` element | `native_test.rb` | `Expected: [nil, nil, "v"] Actual: ["v"]` (3 failures) |
+| 6 | `SERDE-20`: `OMIT` returned at the top level | `native_test.rb` | `Expected Omit to be nil` |
+| 7 | `SERDE-16`/`17`: `#dexpace_load_field` tests `nil?` instead of `key?` | `tristate_decode_test.rb`, `defaults_test.rb` | `Expected Absent to be null?` — the explicit null read as Absent (3 failures) |
+| 8 | `SERDE-18`: `from_nullable(nil)` answers `ABSENT` | `tristate_test.rb` | `Expected Absent to be null?` |
+| 9 | `SERDE-21`: `#integer!` accepts a numeric String | `decode_context_test.rb` | `Dexpace::Serde::DeserializationError expected but nothing was raised` on "string to integer is rejected" |
+| 10 | `SERDE-22`: `#float!` rejects an Integer | `decode_context_test.rb`, `list_test.rb` | `DeserializationError: expected Float at /0, got Integer` (2 errors) |
+| 11 | `SERDE-13`: `#error!` renders the plain form at the root | `decode_context_test.rb`, `codec_load_test.rb` | `--- expected "expected Pet (Hash) at /, got NilClass" +++ actual "expected Hash at /, got NilClass"` (2 failures) |
+| 12 | `SERDE-13`: `Codec#load` builds `DecodeContext.root` with no target | `codec_load_test.rb` | the same diff on "a wire null into a non-null target names the target type", `decode_context_test.rb` green — the two layers are separately pinned |
+| 13 | `SERDE-8`: `List.of` skips `Scalars.resolve` | `list_test.rb` | `NoMethodError: undefined method 'dexpace_load' for class String` (1 failure, 3 errors) |
+| 14 | `SERDE-5`/`8`: `witness?` also accepts `#call` | `witness_test.rb`, `decoding_handler_test.rb` | `Expected true to not be truthy` on the lambda case; the non-witness-at-construction case (2 failures) |
+| 15 | `SERDE-3`: `Codec#load` closes the source | `codec_load_test.rb`, `seam_conformance_test.rb` | `SERDE-3: #load closed the caller's source. Expected: 0 Actual: 1` (4 failures) |
+| 16 | `SERDE-4`: the explicit fit check dropped | `codec_test.rb`, `seam_conformance_test.rb` | `IndexError expected but nothing was raised` — the String silently grew (4 failures) |
+| 17 | `SERDE-4`: `SerializationError` raised for an overflow | `codec_test.rb`, `seam_conformance_test.rb` | `[IndexError] exception expected, not Class: ` (4 failures) |
+| 18 | `SERDE-4`: `cause: nil` dropped | `codec_test.rb`, `seam_conformance_test.rb` | `Expected # to be nil` — the in-flight error chained (3 failures) |
+| 19 | `SERDE-9`/`10`: `strict: true` dropped from the Coder | `codec_test.rb` | **Behaviourally equivalent on both rows**: `JSON::Coder` is strict on its own account on 2.19.9 and 3.0.2 (`Coder.new.dump(Object.new)` raises `GeneratorError` with no option), so `strict: true` is documentation of intent; `Native` is the observable layer and is pinned by the class-naming assertion. Kept, and the YARD says so. **Red since review round 0 through the keyword pin**: `CoderKeywordsTest` — `+++ actual ["allow_duplicate_key=false", "allow_duplicate_key=true", …]`, `strict=true` gone from every line |
+| 20 | `SERDE-9`: `dump_string` re-raises with `cause: nil` | `codec_test.rb`, `seam_conformance_test.rb` | `Expected nil to be a kind of JSON::JSONError, not NilClass` (2 failures) |
+| 21 | `SERDE-12`: the parse rescue widened to `StandardError` | `codec_load_test.rb`, `seam_conformance_test.rb` | **STAYED GREEN on both rows, and is equivalent**: `parse(text)` wraps `@coder.load(text)` alone, and the I/O error arises in `drain(source)` outside it, so widening that rescue cannot reach the stream — the scope, not only the class, is what keeps `SERDE-12` structural |
+| 21.5 | `SERDE-12`: a `StandardError` rescue around the drain, re-raising as `DeserializationError` | `codec_load_test.rb`, `seam_conformance_test.rb` | `[Dexpace::StreamError] exception expected, not Class: ` (6 failures; `[IOError]` on 3.2.11's first line) |
+| 22 | `SERDE-27`: the `ensure response.close` dropped | `decoding_handler_test.rb`, `status_aware_handler_test.rb` | `Expected: 1 Actual: 0` on `body.closes` (8 failures) |
+| 23 | `SERDE-27`: the nil-body and `eof?` screens dropped | `decoding_handler_test.rb`, `status_aware_handler_test.rb` | `[Dexpace::Serde::DeserializationError] exception expected, not Class: ` on the bodyless case and on the anonymous-witness 204; `[Dexpace::StreamError] exception expected, not Class: ` on the `eof?`-probe case, whose double has no `#read`; `Expected /no body/ to match "expected … PetWitness (Hash) at /, got String"` on the empty-body case; the status-aware 204 beside them (4 + 1 failures). **As first recorded the third failure was attributed to the empty-body case; review round 1 found that case GREEN under this guard** — the failure was the `eof?`-probe case — and its `/no body/` assertion is the round's repair (23.5) |
+| 23.5 | `SERDE-27`: the `eof?` probe kept and its raise dropped (`raise missing_body if source.eof?` → `source.eof?`; review round 1's 23c) | `decoding_handler_test.rb`, `composition_test.rb` | `Expected /no body/ to match "expected DexpaceSerdeDecodingHandlerTest::PetWitness (Hash) at /, got String"` on the empty-body case, and `Expected /no body to decode into DexpaceSerdeJSONCompositionTest::Pet:/ to match "malformed JSON: unexpected end of input at line 1 column 1"` on the composed empty 200 through the real codec (2 failures). **Survived every suite on both rows before review round 1** |
+| 24 | `SERDE-28`: the 4xx branch delegates to the decoder | `status_aware_handler_test.rb` | `Dexpace::ProtocolError expected but nothing was raised` (9 failures) |
+| 25 | `SERDE-28`: a second `response.close` in the 4xx branch | `status_aware_handler_test.rb` | `Expected: 1 Actual: 2` on `body.closes` — visible only because `FakeResponseBody` counts raw closes (2 failures) |
+| 26 | `SERDE-28`: the third-branch message does not lead with the code | `status_aware_handler_test.rb`, `composition_test.rb` | `Expected /\A304\b/ to match "Not Modified 304: not decoded into …"` (3 failures) |
+| 27 | `SERDE-2`: `application/octet-stream` as the fallback | `body_serialized_test.rb` | `Dexpace::InvalidArgumentError expected but nothing was raised` on "no format-agnostic default" |
+| 28 | `SERDE-25`: `.default` memoised | `codec_test.rb`, `json_test.rb` | `Expected # to not be the same as #` |
+| 29 | `SERDE-26`: the Coder hoisted to one class-level shared instance | `codec_test.rb` | `Dexpace::Serde::SerializationError expected but nothing was raised` on the `max_nesting: 4` encode case, and `DeserializationError: malformed JSON: nesting of 5 is too deep` on the default's decode (3 failures, 2 errors). A first spelling of this mutation crashed the suite on an unused-variable warning under `NFR-6` and was re-spelled to keep the variable live, as 6b's guards 25 and 46 were |
+| 30 | `P7-6`: the `valid_encoding?` guard dropped | `codec_load_test.rb` | `Dexpace::Serde::DeserializationError expected but nothing was raised` — a UTF-8-tagged invalid String came back |
+| 31 | `SERDE-24`: `#iso8601` with no digits | `instant_test.rb`, `defaults_test.rb` | `Expected: 2025-09-10 12:00:00.123456 UTC Actual: 2025-09-10 12:00:00 UTC` — the microsecond round trips (10 failures) |
+| 31.5 | `SERDE-24`: `time.to_i.to_s` (an epoch number) | `instant_test.rb`, `defaults_test.rb` | `DeserializationError: expected Dexpace::Serde::Instant (Time (ISO-8601)) at /, got String` (6 failures, 6 errors) |
+| 32 | `SERDE-19`: `ABSENT#dexpace_dump` answers `nil` | `defaults_test.rb`, `native_test.rb`, `tristate_test.rb` | `--- expected {"name":"x"} +++ actual {"name":"x","nick":null}` and `Expected nil (oid=4) to be the same as Omit` (7 failures) |
+| 33a | a second `add_dependency "rake"` in the adapter gemspec | `gates:gemspec_audit` | `dexpace-serde-json declares json, rake; NFR-2 allows core plus at most one third-party library.` (a gem outside the bundle, `oj`, is refused earlier still, by Bundler's own resolution) |
+| 33b | `require "json"` in `serde/instant.rb` | `gates:require_allowlist` | `instant.rb:5: require "json" -- SEAM-2: the wire codec is a seam. It lives in dexpace-serde-json, and the >= 2.19.9 floor lives in that gemspec and nowhere else` |
+| 33c | `@coder: ::JSON::Coder` in the adapter's `codec.rbs` | `rbs:validate` | `codec.rbs:20:16...20:29: Could not find ::JSON::Coder (RBS::NoTypeFoundError)` |
+| 33d | `-> ::JSON::State` on a public method in `codec.rbs` | `gates:rbs_surface` | `codec.rbs: public signature references JSON::State, which is outside Dexpace:: and the stdlib allowlist` |
+| 34 | `P7-65`: the explicit `allow_duplicate_key: false` default dropped from `Codec#initialize` (review round 0's X7) | `codec_test.rb` `CoderKeywordsTest` | `--- expected ["allow_duplicate_key=false strict=true", …] +++ actual ["strict=true", "allow_duplicate_key=true strict=true", "max_nesting=4 strict=true", …]` on 4.0.6 with json 3.0.2 and on 3.4.10 with json 2.19.9 pinned unbundled — where `ConstructionTest`'s behavioural duplicate-key case goes red too, through `NFR-6`'s fatal `warning: detected duplicate key "a" in JSON object`, the only row it ever could: on 3.0.2 that case stays green under the mutant, because the library refuses a duplicate key by default |
+| 34.5 | `P7-65`: `encoders:` forwarded to the Coder (`table.compact` in place of `table.except(:encoders).compact`) | `codec_test.rb` `CoderKeywordsTest`, `ConstructionTest` | the third recorded line reads `allow_duplicate_key=false encoders={Time => #} max_nesting=4 strict=true`; on 3.0.2 the library refuses the keyword first (`ArgumentError: unknown keyword: encoders`) and the "encoders: replaces the default table" case errors beside it, while on 2.19.9 only the keyword pin sees it |
+| 35 | `P7-7`: the require-time floor block deleted from `json.rb` (review round 1's N1) | `floor_test.rb` | `Expected /\ASEAM_ERROR json=2\.18\.0 / to match "LOADED json=2.18.0 keys=[:json]"` — the interpreter's stock json loads and registers, the silently-unpatched case the deviation exists for (`2.6.3` on 3.2.11, where the same line goes red). **Survived every suite and every gate on every row before review round 1**: no gate row runs a json below the floor, so the child pins the interpreter's default json by exact version with `gem` and requires the entry file with `RUBYOPT`, `RUBYLIB` and the `BUNDLE_*`/`BUNDLER_*` keys cleared |
+| 36 | `P7-70`: the `"an anonymous witness"` fallback dropped from either handler's `#target_name` (review round 1's R1-4) | `decoding_handler_test.rb`, `status_aware_handler_test.rb` | `Expected /no body to decode into an anonymous witness:/ to match "no body to decode into : the response carried none (SERDE-27)"` and `Expected /\A304 Not Modified: not decoded into an anonymous witness,/ to match "304 Not Modified: not decoded into , only a 2xx body is (SERDE-28)"` (1 failure each) |
+
+## Audit groups run
+
+The phase-start pair first, at implementation: `--origin note --brief` returned 56 note entries across
+22 files; `--section conflicts --brief` returned 25 entries with every one of the six harvested
+conflicts `[overridden by notes/…]` and none open. The thirteenth audit group's `SERDE` slice —
+`--prefix SERDE --section rules,constraints,conclusions` — returned 36 entries across two topic
+files, **zero tagged `[appendix-B roll-up]`**, covering all 30 IDs (the four a `--section rules`
+reading misses, `SERDE-17`, `24`, `25` and `30`, are filed under Constraints and Conclusions — the
+design's narrowness finding, now a one-cell edit of the skill's row); chapter 14 was read in full, its
+`*Conformance:*` clauses included. The five note entries the brief binds were read in full:
+`pipeline/86343352` and `pipeline/7ce4431d` (the carried re-raise is `raise error, cause: nil` — the
+`StatusAwareHandler`'s factory-built error and `#dump_into`'s `IndexError`, and nowhere else, because
+the two codec re-raises are of the error just rescued, inside the rescue, so the chain is wanted),
+`error-handling/5322e965` (no suppressed trail is attached in 7a: no `SERDE` requirement describes a
+two-failure path, and a close raising in the handler's `ensure` propagates over the primary),
+`execution-context/b58728da` (no `private_constant` of `Dexpace` is reached from a compact `module`
+form; `Scalars` is a `private_constant` of `Serde`, named bare from full-nesting bodies) and
+`url-and-query-encoding/08c54234` (7a parses no URL).
+
+| Group | Result at implementation |
+|---|---|
+| Public API surface | `module-organization/1828a984` (one public constant per file) holds: `native.rb` carries `Native` and `OMIT` on 5b's `keys.rb` two-constants precedent with the `Omit` class private, `scalars.rb` carries `BOOLEAN` with `Scalars` private, `tristate.rb` the module with its three members and the private `Combinator`, `codec.rb` the class with its private `Options`; `api-design/b0e18938` is why `Scalars`, `Combinator`, `Omit`, `Absent`, `Null`, the codec's `Options` and every default table are private and why the codec has no `coder` / `options` reader; every public name is in the design's object model or in P7-2/P7-3 |
+| RBS / Steep typing | Twelve new mirrors and three edited in place (`serde.rbs`, `http/body.rbs`, the adapter's `json.rbs`); the strict `core` target green with no relaxation and two `#: Type` annotations on empty-collection locals (the tree's idiom); the `:serde_json` target's one relaxation named and commented; `_Witness` an interface inside `Dexpace::Serde` on the `_Source`/`_Sink`/`_Span` convention, `_Codec` left at `Dexpace::_Codec` where phase 2 put it |
+| Minitest conventions | Every suite subclasses `DexpaceTestCase`; the six suites over 100 code lines are split into nested classes over a shared `Fixtures` module (6c's shape); `assert_same` wherever identity is the claim (the memoised failure, the shared root context, the body's own source handle); no `Hash#inspect` asserted; every parser error asserted by class, never by message; the seeded `sample` helper for both property tests; every thread joined |
+| Encoding and binary strings | Every encoding assertion carries non-ASCII content (`é`, `héllo wörld`, `Ré`); `#dump_bytes` is `.b` of a UTF-8 String, `#dump_into`'s target must be BINARY, `#read_utf8` retags and `P7-6` validates; the ingress retag is never `force_encoding` on a frozen chunk |
+| Serialization, SSE and pagination | Every `SERDE` rule in the group restates a clause implemented above; `serde/b5e5efc8` (the strictness burden on the witness) and `serde/5fe8e3ed` (`key?`) are what `DecodeContext` and `Tristate::Combinator` are built on; `serde/5e420c20`'s `JSON.load` ban is honoured (`::JSON::Coder#load` is `::JSON.parse`'s configured form, and the YARD says so at the call site); no `SSE` or `PAGE` rule is consumed |
+
+## Deviations from the plan
+
+Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. Items
+1–22 are where the built tree overrode the plan's assumptions, in the order the brief's as-built list
+gives them; 23–31 are this build's. The ones that touch public behaviour, the contract a later phase
+cites, or a statement the design makes are also the as-built ledger rows P7-61–P7-72.
+
+1. **`interface _Codec` was never empty and lives at `Dexpace::_Codec`.** Task 12's fence would have
+ written a second interface at `Dexpace::Serde::_Codec`; the existing one was edited in place with
+ the three clauses (P7-61), `_Witness` was put inside `Dexpace::Serde` on the namespace-local
+ convention, and `sig/dexpace.rbs` — a two-line stub — was not touched. The design's headline finding
+ is withdrawn in its As-built addendum with the evidence (all four phase-2 files carry bodies since
+ c881f92), and the roadmap's phase-10 bullet carries a dated bracketed correction.
+2. **Five core pins invalidated by the require-time registration**, converted on the code branch as
+ the manager decided: two subprocess assertions in `seam_surface_test.rb`, one in `serde_test.rb`,
+ and two swap pins asserting the override is gone (P7-63).
+3. **`Response.build(status:, body:)` does not exist and `TypedResponse.new` refuses a non-Response**,
+ so the plan's `CountingResponse` double was never written: every handler test builds a real
+ `Response` through `RecoveryFixtures#build_response` over 3b's `FakeResponseBody`, whose raw
+ `#closes` counter is what makes guard 25's second close visible (P7-64). `Body.buffer` takes a
+ `Dexpace::IO::Buffer`, `Headers#[]` answers the value list, and the 304 headers are built through
+ `headers_with(...).new_builder.add(...)`.
+4. **`FakeCodec` hands its witness BINARY bytes**, so the two upcase witnesses retag before folding, and
+ the "empty body" screen never reaches `nil.upcase` (`#read` answers `""` at EOF).
+5. **The json facts moved**: 3.0.2 everywhere in the bundle, `Coder.new` keywords-only with an unknown
+ key refused, `encoders:` refused, a duplicate key a `ParserError`. Hence the option allowlist, the
+ keyword-only construction, `allow_duplicate_key: false` fixed, and `encoders:` never forwarded
+ (P7-65); the design's open question 4 premise is true at the floor and false at 3.0, and the codec's
+ YARD says so.
+6. **The empty body is detected with `BufferedSource#eof?`**, never `#content_length` (which is `-1` for
+ every unknown-length body) and never a parser message; the plan's "convert a codec-side end-of-input
+ `ParserError`" clause was not written (P7-67).
+7. **RuboCop refused the fences as written**: `Codec.build` and `JSON.build` take one positional Hash
+ (`Model#with`'s idiom) rather than a `**` splat; every rescue variable is `error`; `Native`,
+ `Instant` and `Scalars` use `extend self`; `Native.of` is a scalar/collection/encoded trio of private
+ helpers; `dump_into`'s validation is a private `buffer!`; the codec's option validation is a private
+ `Options` module (`Metrics/ClassLength`); six suites are split into nested classes; and every test
+ line is under 100 columns.
+8. **Steep on `::JSON::Coder`**: json 3.0.2 ships no `sig/`, so the manager's first route was closed and
+ the second taken — the `:serde_json` target alone downgrades `Ruby::UnknownConstant` to
+ `:information`, commented, with the ivar typed `untyped` (P7-62); `rbs_collection.yaml` needed no
+ row and is as `main` has it — the feat commit had corrected its stale comment, and review round 0
+ (R0-1) had the hunk dropped as a rewrite of a shared file outside this phase's bounds; the
+ correction is routed (Findings routed).
+9. **The gemspec line landed before the first `require "json"`**, and every one of
+ `gates:gemspec_audit`, `gates:require_allowlist` and `gates:clean_bundle` passed on every row;
+ `clean_bundle` fetched json 3.0.2 into 3.3.12's and 3.4.10's gem directories (Findings routed).
+10. **The `SEAM-2` scan reads code, not comments, through Ripper**, because `serde.rb` and
+ `http/method.rb` already name the adapter in a comment and the plan's raw `include?` would have
+ failed on `main`; the test excludes itself by `File.expand_path(__FILE__)` (P7-68).
+11. **Every `Data` has a public `.build` over ALL its members, `private_class_method :new, :[]` and
+ validation in `#initialize`** (P7-66): `DecodeContext.build(path:, target:)` is public and `#at`
+ routes through it; `StatusAwareHandler`'s members are `(serde, witness, factory)` with the
+ `DecodingHandler` derived in `#initialize`; `Present#initialize` carries `Model.required!` so `.[]`
+ and `send(:new, …)` meet the same check.
+12. **Every nested `private_constant` is declared in its `.rbs`** with the tree's comment, and the test
+ mirrors are one per `lib/` file (`list_test.rb`, `map_test.rb`, `nullable_test.rb`,
+ `scalars_test.rb`, never one `combinators_test.rb`), with `tristate_decode_test.rb` and
+ `body_serialized_test.rb` as extras beside the mirrors.
+13. **`blank?` does not exist**; `Body.serialized` writes the nil/empty test out, and its `serde:` is
+ also checked for `#dump_bytes` and `#media_type` so a non-codec is refused by name.
+14. **The default factory is a frozen lambda**, `ErrorMappingStep`'s shape, never
+ `ProtocolError.method(:for)`; the three-branch dispatch reads `Response#success?` / `#error?`.
+15. **The composition slice runs `Pipeline.standard` over a three-positional lambda**, since core's
+ `FakeTransport` is out of another gem's test tree (styleguide 12.6) and the plan's
+ `require_relative "../../../support/fake_transport"` resolves to nothing; the recording transport
+ is a small class whose `#to_proc` is the lambda (P7-69).
+16. **The docs edited for 7a only**, on top of what `main` says: `CLAUDE.md`'s built-phase sentence, the
+ layer paragraph, 184 → 195, fifteen checklists, the adapter's `lib/` sentence, five "Constraints"
+ lines; `docs/README.md`'s fifteen pages; root `README.md`'s gem table and skeleton sentence;
+ `architecture.md`'s `serde.md` entry with the `write-a-serde.md` placeholder left in place.
+17. **The register entries the design already filed were verified and not re-filed**:
+ `docs/first-release.md`'s `7a P7-1` entry (its first owed half now closed by `serde.md`), the
+ `dexpace-serde-oj` second motive, the generated-style worked-example blocker; the knowledge-lookup
+ row's Query column gained `constraints,conclusions`; `docs/knowledge/notes/serde.md` is new with the
+ drafted UTF-8 entry and a second entry on the `Coder` option drift.
+18. **The `# Phase 7a:` block is at the END of `lib/dexpace.rb`**, after 6b's, in dependency order;
+ `witness.rb` reopens `Dexpace::Serde` and `serde.rb`'s body is untouched; the `LAYERS` table is
+ untouched.
+19. **`strict: true` is not observable past `Native`** — and, sharper than the brief's point 19,
+ `JSON::Coder` is strict on its own account, so guard 19 is an equivalent mutant behaviourally on
+ every row; kept as documentation of intent, with a Native-bypassing pin that drives the private
+ engine directly, and — since review round 0 — pinned at the keyword level with
+ `allow_duplicate_key: false` by `CoderKeywordsTest` (guards 19, 34, 34.5).
+ `raise ::IndexError, …, cause: nil` is asserted from inside a `rescue` (guard 18 red).
+20. **Ledger numbering** from P7-61 (P7-61–P7-72); no design row renumbered; 7b's and 7c's rows never
+ cited.
+21. **Nothing installed**: all four interpreters were present; the facts were re-run on all four and
+ json 2.19.9 was reached by pinning it in an unbundled subprocess on 3.4.10 from the cross-check's
+ scratch gem home.
+22. **The manager's four decisions were followed as written**: route (2) for Steep; the child-process
+ pins on the code branch; the roadmap bullet's bracketed correction; `clean_bundle`'s fetch recorded
+ once and routed, and `clean_bundle_check` not edited.
+23. **`DecodeContext.root` names an anonymous class as nothing** — `Module#name` is nil for
+ `Class.new`, so the root falls back to the shared no-target instance rather than rendering
+ `#` into every message (P7-70). Since review round 1 (R1-4) the two handler messages
+ that interpolate that target — `DecodingHandler#missing_body` and
+ `StatusAwareHandler#unhandled_message` — fall back to the literal `"an anonymous witness"` through a
+ private `#target_name`, so a 204 or a 304 through an anonymous witness no longer reads
+ "decode into : the response"; the context's own rule is unchanged.
+24. **`#pointer` is RFC 6901 exact (`""` at the root) and `#error!` renders the root frame as `/`** for
+ readability, the design's own message form; a same-named `#present!` target at the root is not
+ doubled.
+25. **`Native`'s Hash keys are String or Symbol, coerced to String, and anything else raises** — an
+ Integer key is refused rather than stringified, the loud direction the walk exists for; a Symbol
+ VALUE is not native and raises, and the design's rule 7 is followed rather than softened (P7-71).
+26. **`Native`'s encoder lookup is exact class first, then the first `is_a?` match in table order**, so
+ `DateTime` finds its own entry before `Date`'s and a caller's `Numeric` entry catches a `Rational`.
+27. **`Codec#load` accepts a raw IO answering `#read`** beside a `BufferedSource`, by wrapping it in a
+ dropped `BufferedSource.wrapping` — the same read, the same ceiling, the caller's IO left open —
+ because phase 2's own seam test passes a `StringIO` and `SERDE-3`'s subject is "a caller-supplied
+ stream" (P7-72).
+28. **`Codec#dump_to` answers `bytes.bytesize` rather than the sink's return value**, so the count is
+ the codec's and a sink answering something else cannot mis-report it; a sink without `#write` is
+ refused by name.
+29. **The 304 message form is `: not decoded into , only a 2xx body is
+ (SERDE-28); etag: …; location: …`**, the raw values joined with `", "` per header the way a
+ header line reads.
+30. **`Body.serialized`'s test suite is `http/body_serialized_test.rb`**, beside 3b's `body_test.rb`,
+ never inside it.
+31. **The smoke test's "defines nothing outside Dexpace" pins `Codec MINIMUM_JSON_VERSION
+ REQUIRED_CORE VERSION`** as the module's four constants and `:JSON` as the one sibling added, with
+ the snapshots taken after `require "dexpace"`, `json`, `time` and `date`.
+
+## Findings routed
+
+- **The design's four findings were verified at their owners and not re-filed**, with one withdrawn:
+ the `_Codec` finding rests on a false premise — all four phase-2 `sig/` files have carried bodies
+ since c881f92 — and is withdrawn in the design's As-built addendum, with the roadmap's phase-10
+ bullet corrected in place by a dated bracketed sentence (the manager's decision 3); the
+ `SERDE`-audit-group narrowness is a one-cell edit of `.claude/skills/knowledge-lookup/SKILL.md`'s
+ thirteenth row, applied; the `SERDE-27` release entry in `docs/first-release.md` is cited by the
+ `SERDE-27` row, and its first owed half — the documented ceiling behaviour — is closed by
+ `docs/sdk-documentation/serde.md`, the entry updated to say so and to leave the phase-8 half open;
+ `dexpace-serde-oj`'s second motive is already on its post-v1 entry.
+- **New, routed to phase 10's inbound list** as audit-or-repair work against the phase-0 gate, by date
+ and content: `gates:clean_bundle` installs into the running interpreter's gem directory and, on the
+ first adapter with a third-party dependency, fetched json 3.0.2 from rubygems.org into 3.3.12's and
+ 3.4.10's on this run; the repair is a `BUNDLE_PATH` under the scratch directory. Phase 8a's Task 23
+ owns `clean_bundle_check` this wave and may close it there.
+- **New, routed to phase 10's inbound list by date and content, after review round 0 (R0-1)**:
+ `rbs_collection.yaml`'s header comment says "json arrives with dexpace-serde-json's codec in phase 7",
+ and 7a added no row — `json`'s signatures are rbs's own stdlib set, resolved already, and json 3.0.2
+ ships no `sig/` — so the sentence is stale on the tree and stays stale, because the file is a shared
+ one outside 7a's bounds that phase 8a's first row rewrites; whichever lane adds the first row closes
+ it, and the roadmap's bullet says so.
+- **New corpus note**, `docs/knowledge/notes/serde.md`, two `## Reference` entries: the UTF-8 validation
+ the design drafted, and the json 2.19.9 → 3.0 `JSON::Coder` option drift the build measured, both
+ with the keys they rest on cited in support (`[cited by]`, never overriding).
+- **The design's ledger** gains an "As built" addendum (P7-61–P7-72); the consolidation of P7-1–P7-9
+ and P7-61–P7-72 into design §10 is a human's, as for every phase since 3a, because
+ `docs/sdk-design-ruby/` is frozen. `docs/deviations.md` is untouched, for phase 10 to flip.
+- **8a's identical conversion of `seam_surface_test.rb:17` and `:22`** is the manager's reconcile chore:
+ both stacks are green alone, and one copy is kept at merge.
+
+## Postponed work
+
+**None of 7a's own.** All thirty IDs are implemented; design §12's `SERDE` row ("*Deferred:* none") is
+unchanged. **What earlier phases postponed here has landed**: phase 3b's `TypedResponse` has its two
+handlers and the ninth body factory; phase 2's `_Codec` clauses are settled; phase 3's third ownership
+rule (`message-bodies/a7afc6ee`) is implemented whole. **What this phase leaves to others, none of it its
+own to defer**: `SERDE-27`'s no-materialization clause (`7a P7-1`, the `docs/first-release.md` entry,
+whose remaining half is phase 8's pull-parser check); the phase-9 lift of
+`gems/dexpace-serde-json/test/support/serde_seam_assertions.rb` into `dexpace-conformance`; the
+`clean_bundle` install location (phase 10, or 8a's Task 23); the wire-boundary re-validation of the
+`Content-Type` `Body.serialized` implies (phase 8a's Task 16, phase 8c's Task 9). 7b and 7c are
+independent of this sub-phase and of each other, and neither consumed anything here.
diff --git a/docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-design.md b/docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-design.md
index 9f05990..41c78d4 100644
--- a/docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-design.md
+++ b/docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-design.md
@@ -1707,6 +1707,120 @@ them, **every citation of a phase-7 row from outside its own sub-phase carries t
| P7-8 | **`SERDE-24`'s round-trip guarantee is stated as holding for a `Time` whose `subsec` is an exact multiple of one microsecond**, and the encoder truncates outside that domain | `SERDE-24`; design §3.4's "the round-trip **SERDE-24** requires holds by construction"; verified fact 9 | Measured: `Time#iso8601(n)` **truncates**, so `Time.new(2026,9,10,12,0,0.123456,"+02:00").iso8601(6)` is `…00.123455+02:00` — one microsecond low — and the round trip fails. The `Float` second is stored as `8895942329546431/72057594037927936` ≈ `0.12345599999…`, and a `subsec` with no finite decimal expansion (`Rational(1,3)`) fails at any width. Integer-microsecond times round-trip **exactly** at `iso8601(6)`, which is every `Time` this SDK constructs and every `Time` `Instant` decodes. The port states the domain in the YARD with the measured example and asserts both halves in tests, rather than claiming "by construction" for a guarantee that measurably has an edge. Rounding instead of truncating would mean a second date formatter beside 5a's `HTTPDate` |
| P7-9 | **The tri-state omission is core's `Native` walk, not each model's `#dexpace_dump`**, where design §7.3 writes "`#dexpace_dump` builds the `Hash` and simply omits Absent keys before `JSON.generate`" | `SERDE-15`, `SERDE-19`, `SERDE-20`; design §7.3 | §7.3's route makes `SERDE-19`'s MUST a convention every hand-written model must remember, and the requirement names the exact failure of forgetting it: "absent this wiring, Absent and Null become indistinguishable on the wire" — silent, on the wire, in a PATCH. Moving the drop into the walk makes the wiring **structural**, which is the word §7.3 itself uses for what `SERDE-19` needs, and puts `SERDE-20`'s three degradations (top-level, array element, in-object) in one place instead of in every model. A model that omits its own Absent keys still works; the walk simply has nothing to drop. It also means `dexpace-serde-oj` inherits tri-state encoding by calling `Native.of`, which is `serde/66ebd950`'s "no second code path in core" |
+
+### As built, 2026-09-20
+
+This sub-phase was cut from `main` at `c53638b`, which holds every phase through 6b, and built in
+parallel with 7b, 7c and 8a on the same base; nothing of theirs exists on this tree. The nine rows
+above stand. Execution added the rows below, numbered from **P7-61** as the manager fixed it (7b's
+as-built band starts at P7-81 and 7c's at P7-101; 7b's and 7c's design ledgers knowingly share
+P7-1–P7-n with this one until phase 10's consolidation, and a phase-7 row is cited from outside its own
+sub-phase with the letter). The checklist's "Deviations from the plan" is the itemised list against the
+plan's text; the rows here are the ones that touch public behaviour, the contract a later phase cites,
+or a statement this document makes.
+
+**One finding above is withdrawn.** *Findings, and who owns them now* records that "phase 2 declared
+`interface _Codec` and never wrote its body" and hands phase 10 the question of whether the three
+other declared `sig/` files are empty too. The premise is false on the tree: commit c881f92 (#44)
+wrote all four with real bodies — `sig/dexpace/serde.rbs` declares `interface _Codec` at
+`Dexpace::_Codec` (beside 3b's `_ResponseHandler`, not under `Dexpace::Serde`) with all six methods
+typed, and `serde/error.rbs`, `serialization_error.rbs` and `deserialization_error.rbs` declare
+`class Error < ::StandardError; include Dexpace::Error` and its two subclasses. What this document read
+as unwritten was the *plan's* Step 6 prose giving no types, not the file. The `#media_type` clause the
+finding turns on was still real — phase 2 typed it `() -> String` where `FakeCodec` answers a String
+and `Body#media_type` a `MediaType?` — and is settled by P7-61 below; the "no gate can see an empty
+declaration" hazard has no instance here, and the roadmap's phase-10 inbound bullet carries a dated,
+bracketed correction in place rather than a deletion. No action for phase 10.
+
+**Two statements above read differently against the tree, and the difference is recorded rather than
+by rewriting the text it corrects:**
+
+- **`strict: true` is documentation, not a switch.** Verified fact 3 and *The object model* say the
+ adapter "passes `strict: true`" as a second layer beside `Native`; measured on json 2.19.9 and 3.0.2,
+ `JSON::Coder` is strict on its own account — `Coder.new.dump(Object.new)` and `.dump(Time.at(0))` raise
+ `GeneratorError` with no option at all — so removing the option changes nothing a test can see
+ behaviourally (the checklist's guard 19, an equivalent mutant on every row until review round 0's
+ keyword pin, below). It is kept as intent, and `Native` is the observable layer, pinned by the
+ class-naming assertion and by a test that drives the private engine directly.
+- **Open question 4's premise is version-bound.** "`::JSON::Coder.new` accepts unknown options silently"
+ is true at the 2.19.9 floor and false at 3.0, where an unknown keyword is an `ArgumentError` and
+ `encoders:` is refused outright; the allowlist the question recommends is what makes the two one
+ `InvalidArgumentError` (P7-65), and `docs/knowledge/notes/serde.md` carries the measurement.
+
+| # | Deviation | Requirement / document | Why |
+|---|---|---|---|
+| P7-61 | **`interface _Codec` is edited in place at `Dexpace::_Codec`, never written a second time at `Dexpace::Serde::_Codec`**: `#media_type` widened to `(Dexpace::MediaType \| String)`, `#load` narrowed over a new `Dexpace::Serde::_Witness`, `#dump_to` narrowed to `Integer`; `_Witness` lives inside `Dexpace::Serde` on the `_Source`/`_Sink`/`_Span` namespace-local convention | `SEAM-19`, `SERDE-2`, `NFR-3`, `NFR-4`; *The `sig/` shape*; the plan's Task 12 | The plan's fence would have declared a duplicate interface under a second constant path; phase 2's file already carried the six methods, and a widening of a return type and a narrowing of a parameter type on an interface no release tag has locked is the edit `NFR-4` permits. The seam interfaces at `Dexpace::_X` are phase 2's shape and are not the one 7a copies for a new interface |
+| P7-62 | **The Steepfile's `:serde_json` target downgrades `Ruby::UnknownConstant` to `:information`**, with a comment naming the missing `JSON::Coder`; the codec's `@coder` is typed `untyped` and no `::JSON` type appears in the gem's `sig/` | `NFR-3`, `NFR-11`; `CLAUDE.md`'s "every relaxation is a named target in the Steepfile" | rbs 4.2.0's stdlib json signatures declare `JSONError`, `GeneratorError`, `ParserError`, `State`, `generate` and `parse` and no `Coder`, and json 3.0.2 ships no `sig/` for `rbs collection` to pick up — so the manager's first route was closed and the second taken. A line-level `steep:ignore` has no precedent in `lib/`; core's strict target is untouched. Re-tighten at the first rbs release declaring `JSON::Coder` |
+| P7-63 | **Five core registry pins are converted on the code branch**: `seam_surface_test.rb`'s two seam-iterating pins and `serde_test.rb`'s "starts empty" pin assert in a child process that requires `dexpace` alone; `serde_test.rb`'s two swap pins assert the swapped-in codec is no longer what resolves, never that nothing resolves | `SEAM-2`, `SEAM-5`, `SEAM-6`; design §3.6; phase 2's suites | Registration at require is design §3.6 and phase 2's contract, and `rake test:gems` runs every gem's suite in one process, so `Dexpace::Serde.resolve` answers the JSON codec inside core's own suite the moment the adapter's smoke test loads its entry file; `Registry#swap` restores `resolved` and deliberately never `factories`. The pins, not the registration, are what change; 8a converts the same two `seam_surface_test.rb` lines and the reconcile pass keeps one copy |
+| P7-64 | **3b's `FakeResponseBody` is the counting body in every handler test, over a REAL `Response` from `RecoveryFixtures#build_response`; the plan's `CountingResponse` double does not exist** | `SERDE-27`, `SERDE-28`, `HTTP-44`; `R3`; the plan's Task 1 | `TypedResponse.new` type-checks its response, so a Response-shaped double cannot be handed to it; and `FakeResponseBody#closes` counts RAW close calls, which is the only counter that can read 2 when the 4xx branch adds a second close (4b's `Closeable`-latched bodies never can) |
+| P7-65 | **The codec constructs its `JSON::Coder` with keywords only, fixes `strict: true` and `allow_duplicate_key: false` (the latter unless the caller opts in), never forwards `encoders:`, and takes its options as one positional Hash validated against a five-key allowlist** | `SERDE-9`, `SERDE-19`, `SERDE-26`; open question 4; `P7-4` | json 2.19.9 takes a positional options Hash and swallows an unknown key; json 3.0 takes keywords, refuses an unknown one and refuses `encoders:`; a duplicate key is last-wins on 2.9, a `-w` warning on 2.19.9 (which the suite's fatal-warnings base turns into an error) and a `ParserError` on 3.0. The allowlist and the fixed options are what make a typo one `InvalidArgumentError` and a duplicate key one `DeserializationError` across the range; the positional Hash is `Model#with`'s idiom, because `Dexpace/NoKeywordSplat` refuses a `**` splat on a public library method |
+| P7-66 | **Every public `Data` in the layer has a public `.build` over ALL its members, `private_class_method :new, :[]`, and its validation in `#initialize`**: `DecodeContext.build(path:, target:)` is public (the plan's "internal `__build`" cannot be, because `Model#with` calls `self.class.build(**to_h, **changes)`), and `StatusAwareHandler`'s members are its three build keywords with the `DecodingHandler` derived in `#initialize` | `HTTP-3`, `HTTP-4`, `SEAM-29`, `SERDE-14`; *The object model*; the plan's Tasks 2, 4 and 11 | `Data.define` generates `.[]` as a second public constructor that `private_class_method :new` alone leaves open (measured: `Present[value: nil]` would be the fourth state), the tree's newer `Data`s write both, and a `.build`-only check misses `.[]` and `send(:new, …)` — validation in `#initialize` is the construction pattern `CLAUDE.md` fixes and is what closes `SERDE-14` on every path |
+| P7-67 | **`DecodingHandler` screens an empty body with `BufferedSource#eof?`**, obtained once with the source it then hands to `#load`; never `#content_length`, never a parser message | `SERDE-27`; open question 3; the plan's Task 10 | `#content_length` is `-1` for every unknown-length body (3b's default), and the "unexpected end of input" text differs between json versions, so both of the plan's routes were wrong on the tree; `eof?` is a non-consuming probe (`fill_once_if_empty` then `buffered.zero?`), and `BufferBody#source` is a fresh view per call, which is why the handle is obtained once |
+| P7-68 | **The `SEAM-2` negative test reads CODE, not comments — Ruby files tokenised with Ripper and their comment tokens dropped — and excludes itself by `File.expand_path(__FILE__)`** | `SEAM-2`; `R12`; the plan's Task 1 | `lib/dexpace/serde.rb` and `lib/dexpace/http/method.rb` already name `Dexpace::Serde::JSON` in a comment explaining the shadowing hazard, so the plan's raw `include?` fails on `main`; and under `ruby path/to/test.rb` `__FILE__` is relative while the glob is absolute, so `path != __FILE__` would flag the test's own comment |
+| P7-69 | **The composition slice runs `Pipeline.standard` over a three-positional recording lambda, never core's `FakeTransport` and never `Pipeline.direct`** | `SEAM-26`, `SEAM-27`, `SERDE-28`, `RECOV-15`, `PIPE-39`; the plan's Task 18 | Core's `FakeTransport` lives in core's test tree, which another gem's suite does not reach into (styleguide 12.6; phase 8a declined moving the fakes), and `Dexpace::Transport.conforms?` accepts a three-positional lambda; `Pipeline.standard` is the preset a generated SDK gets and the spelling 8a's socket twin uses, so the slice exercises the three pillars rather than the bare runtime |
+| P7-70 | **`DecodeContext.root(target:)` gives an anonymous class no target** — `Module#name` is nil for `Class.new`, and the root then falls back to the shared no-target instance | `SERDE-13`; `R2`'s `.root` paragraph | The design's `witness.name` would render `#` into every message for an anonymous witness, which is the object-id noise `SERDE-30` exists to avoid elsewhere; an unnamed class has no name to carry and its message keeps the plain form. **Amended after review round 1 (R1-4):** the two handler messages that interpolated this nil target — `DecodingHandler#missing_body` and `StatusAwareHandler#unhandled_message` — read "decode into : …"; each now derives the name through a private `#target_name` falling back to the literal `"an anonymous witness"`, and the rule on the context is unchanged |
+| P7-71 | **`Native` coerces String and Symbol Hash keys to String and refuses every other key class; a Symbol VALUE is not native and raises** | `SERDE-9`, `SERDE-15`; *The object model*, rules 3 and 7 | The design's "keys are coerced to String and a non-String-able key raises" is settled in the loud direction: an Integer or an object key silently stringified is the quiet mistake the walk exists to refuse, and a Symbol value going out as a string would be the one coercion the walk performs — a caller maps it with an `encoders:` entry |
+| P7-72 | **`Codec#load` accepts a raw IO answering `#read` (or `#readpartial`) beside a `Dexpace::IO::BufferedSource`**, wrapping it in a `BufferedSource.wrapping` that is dropped and never closed | `SERDE-3`, `SERDE-12`, `SEAM-21`; `R1` | `SERDE-3`'s subject is "a caller-supplied stream", phase 2's own seam test passes a `StringIO`, and the wrapper gives a raw IO the same `#read_utf8` drain and the same materialisation ceiling; the wrapper takes ownership by `IO-6` and is never closed, so the caller's IO stays open, and a raw IO's own `IOError` propagates unwrapped like a `StreamError` |
+
+**What the build did not change.** `P7-1`'s deviation stands exactly as stated; `docs/sdk-documentation/serde.md`
+now states the ceiling behaviour, closing the first owed half of the `docs/first-release.md` entry.
+`P7-5`'s `IO::Buffer` refusal is asserted, and the adapter's suite scans its own source for a Ruby
+`IO::Buffer` construction (core's `Dexpace::IO::Buffer` excluded by name). `P7-8`'s domain is asserted
+both ways and by a seeded sample. No mutex, no `Enumerator`, no `close_quietly`, no suppressed trail,
+no URL parse, no regexp and no cause walk were written, as *The spec-forced boundaries, honoured*
+commits.
+
+**Review round 1, 2026-09-20.** Round 0 of the stack's review found no behaviour this document states
+that the code fails to honour, and adds no row: its two should-fix findings were a coverage gap behind
+P7-65 and a scope breach on the code branch. P7-65 says the codec "fixes `allow_duplicate_key: false`
+unless the caller opts in", and `Codec#initialize` does — but the only test of it drove a duplicate key
+through the engine, and the bundle's json 3.0.2 raises `ParserError` on a duplicate key *by default*,
+so dropping the codec's explicit option left every suite green on every gate row and reverted to
+json 2.19.9's warning-plus-last-wins only at the floor, which no gate row runs (the reviewer's X7,
+caught by hand at 2.19.9 alone). The option is now pinned where it lives rather than through the
+engine's behaviour: `codec_test.rb`'s `CoderKeywordsTest` runs a child process that prepends a recorder
+onto `::JSON::Coder`'s singleton class — a permanent patch to a library class, so never in the suite's
+own process, `context_store_config_test.rb`'s shape — and asserts every keyword `.new` receives across
+four constructions: `allow_duplicate_key: false` and `strict: true` on the default, the caller's opt-in,
+`max_nesting` forwarded and `encoders:` withheld, the two booleans. The mutation is the checklist's
+guard 34, red on 4.0.6 with json 3.0.2 and on 3.4.10 with json 2.19.9 pinned unbundled; the same pin
+turns guard 19 (`strict: true` dropped), an equivalent mutant behaviourally, red at the keyword level,
+and holds the "never forwards `encoders:`" clause (34.5). The scope breach: the feat commit had
+rewritten three lines of `rbs_collection.yaml`'s header comment to correct its stale "json arrives with
+the codec in phase 7" sentence, a shared file this phase's brief lists out of bounds and phase 8a
+rewrites with its first row; the hunk is dropped on the code branch, the file is as `main` has it, and
+the stale sentence is routed by date and content to phase 10's inbound list for whichever lane adds the
+first row to close. No `lib/` line changed; the round's three nits — five response fixtures
+`docs/sdk-documentation/serde.md`'s last example used without defining, a `CLAUDE.md` sentence counting
+three serde pins in the child process where one is, and the roadmap note naming the Steep relaxation
+"route (1)" where it is the second route of decision (1) — are the page's, the summary's and the
+roadmap's, not this document's.
+
+**Review round 2, 2026-09-20.** Round 1 of the stack's review ran fifty-nine mutations of its own and
+found two surviving on both rows, each against a behaviour this document states and the checklist claimed
+pinned; it adds no row, because in both the code honoured the statement and the proof did not. The first is
+`R3`'s zero-byte clause, P7-67: `raise missing_body if source.eof?` reduced to a bare `source.eof?` left
+every suite green, because the empty-body case asserted only that the message names `PetWitness`, which the
+witness's own `ctx.object!("")` failure names too — and through the real codec an empty 200 then surfaced
+as `malformed JSON: unexpected end of input`, naming no target. The case now asserts `no body` beside the
+target, and `composition_test.rb` drives an empty 200 through `Pipeline.standard` and the real codec,
+asserting the handler's message and refusing the parser's (the checklist's guard 23.5; guard 23's row is
+corrected, its third failure having been the `eof?`-probe case). The second is P7-7 itself: the
+require-time floor assertion was exercised by no test — every gate row runs the bundle's json 3.0.2, above
+the floor by construction — so deleting the block left all six adapter suites and every gate green, while
+json 2.18.0, stock Ruby 4.0's, HAS a `JSON::Coder` and loads clean without it, the silently-unpatched case
+the row names. `json/floor_test.rb` now drives the raise in a child process with `RUBYOPT` (bundler's
+`-rbundler/setup`), `RUBYLIB` and the `BUNDLE_*`/`BUNDLER_*` keys cleared and `GEM_HOME`/`GEM_PATH` kept,
+pinning a json by exact version with `gem` before the require: the interpreter's default json — 2.6.3,
+2.7.2, 2.9.1 and 2.18.0 across the matrix, every one below the floor — is refused with the `SeamError`
+naming the floor and the active version, and the json the parent runs loads and registers under `:json`;
+the first case's expectation is computed from the same comparison the entry file makes, so a future Ruby
+whose default json clears the floor keeps it meaningful (guard 35). The round's one `lib/` change is the
+nit behind P7-70's amendment above: an anonymous witness rendered as an empty name in the two handler
+messages and now reads `an anonymous witness` (guard 36), a change to two private methods, two `sig/`
+lines and no public surface. The remaining nit — the roadmap's status line still carrying round 0's run
+count — is the roadmap's.
+
---
## Work phase 7a postpones, and who owns it now
diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb
index 8bd43f5..1044305 100644
--- a/gems/dexpace-core/lib/dexpace.rb
+++ b/gems/dexpace-core/lib/dexpace.rb
@@ -307,6 +307,27 @@
require_relative "dexpace/page/async_paginator"
require_relative "dexpace/page/fetchers"
+# Phase 7a: the serialization layer, in dependency order -- after 6b's block, because the two
+# handlers read 3b's Response and Body and 4b's Recovery and ProtocolError, and the whole layer
+# raises through phase 2's Serde::DeserializationError. The decode context first (every witness
+# raises through it), the witness predicates (every combinator is validated by them), the encode
+# walk and its OMIT sentinel (Tristate's #dexpace_dump returns it), the scalar-witness table, the
+# tri-state type with its combinator, the three container combinators, the ISO-8601 witness, and
+# the two response handlers last. No namespace file: Dexpace::Serde is phase 2's serde.rb, which
+# witness.rb reopens. `time` is required by instant.rb, the file that names it; it has been on
+# the allowlist since phase 5a.
+require_relative "dexpace/serde/decode_context"
+require_relative "dexpace/serde/witness"
+require_relative "dexpace/serde/native"
+require_relative "dexpace/serde/scalars"
+require_relative "dexpace/serde/tristate"
+require_relative "dexpace/serde/list"
+require_relative "dexpace/serde/map"
+require_relative "dexpace/serde/nullable"
+require_relative "dexpace/serde/instant"
+require_relative "dexpace/serde/decoding_handler"
+require_relative "dexpace/serde/status_aware_handler"
+
# The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client.
#
# This file issues explicit `require_relative`s for the whole tree rather than using an
diff --git a/gems/dexpace-core/lib/dexpace/http/body.rb b/gems/dexpace-core/lib/dexpace/http/body.rb
index 2cca96e..6ec7138 100644
--- a/gems/dexpace-core/lib/dexpace/http/body.rb
+++ b/gems/dexpace-core/lib/dexpace/http/body.rb
@@ -5,6 +5,8 @@
require_relative "../io/buffer"
require_relative "../error/stream_error"
require_relative "../error/invalid_argument_error"
+require_relative "../model"
+require_relative "media_type"
module Dexpace
# The request/response body contract, the factory home, and the two copy routines every variant
@@ -226,6 +228,44 @@ def self.buffer(buffer, media_type: nil)
Dexpace::BufferBody.new(buffer, media_type: media_type)
end
+ # SERDE-2's factory, the ninth beside phase 3b's eight (phase 7a): a value plus a serde,
+ # with the serde's declared media type as the body's -- "that media type MUST be used as the
+ # default Content-Type when a request body is created from a value plus a Serde". Returns a
+ # replayable BytesBody over `serde.dump_bytes(value)`, so the body knows its exact length and
+ # is retryable with #to_replayable doing nothing (BODY-1, HTTP-38).
+ #
+ # The seam's `#media_type` answers a Dexpace::MediaType or a String (`_Codec`, settled by
+ # phase 7a): a MediaType passes through and a String is parsed through phase 1's
+ # MediaType.parse, which runs the outbound header-value grammar first, so a codec answering
+ # a value a Content-Type header could not carry is refused there, naming the value. A nil or
+ # empty media type raises naming the codec's class: SERDE-2's "MUST NOT be defaulted to a
+ # format-agnostic constant at the SPI level" means there is no fallback to fall back to --
+ # phase 2 enforces the presence at .conforms?, and this is the same rule at the one call site
+ # that consumes the value.
+ #
+ # @param value [Object] anything the serde can encode
+ # @param serde [Dexpace::_Codec] the serde, `#dump_bytes` and `#media_type` at least
+ # @return [Dexpace::BytesBody]
+ # @raise [Dexpace::InvalidArgumentError] on a nil serde ("serde is required"), one answering
+ # neither seam method, or a nil, empty or unparseable media type
+ # @raise [Dexpace::Serde::SerializationError] from the serde, on an unencodable value
+ def self.serialized(value, serde:)
+ Dexpace::Model.required!("serde", serde)
+ unless serde.respond_to?(:dump_bytes) && serde.respond_to?(:media_type)
+ raise Dexpace::InvalidArgumentError,
+ "serde must answer #dump_bytes and #media_type (the codec seam), got #{serde.class}"
+ end
+
+ declared = serde.media_type
+ # SERDE-2: no format-agnostic default, so a codec that declares nothing is a caller mistake.
+ if declared.nil? || (declared.respond_to?(:empty?) && declared.empty?)
+ raise Dexpace::InvalidArgumentError, "#{serde.class} declared no media type (SERDE-2)"
+ end
+
+ media_type = declared.is_a?(Dexpace::MediaType) ? declared : Dexpace::MediaType.parse(declared)
+ bytes(serde.dump_bytes(value), media_type: media_type)
+ end
+
# ---- BODY-32's cap rules, shared by every capped operation --------------------------
# BODY-32: reject a negative cap, silently clamp down to the ceiling, never up. Float::INFINITY
diff --git a/gems/dexpace-core/lib/dexpace/serde/decode_context.rb b/gems/dexpace-core/lib/dexpace/serde/decode_context.rb
new file mode 100644
index 0000000..1483e52
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/decode_context.rb
@@ -0,0 +1,250 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "../error/invalid_argument_error"
+require_relative "deserialization_error"
+
+module Dexpace
+ module Serde
+ # The `ctx` every witness receives (design §7.3's `.dexpace_load(parsed, ctx)`), and the ONE
+ # raise site for a decode shape failure -- Model.required!'s discipline applied to a second
+ # family of failures, so SERDE-13's "naming the target type, across every decode overload" is a
+ # property of one method rather than of every witness anyone writes.
+ #
+ # Emphatically not phase 4a's Dexpace::Context: a decode happens with no pipeline in sight
+ # (`serde.load(source, Pet)` is a legitimate call), coupling the codec seam to the execution
+ # context would push against SEAM-2 and NFR-11, and no hand-written witness has a use for an
+ # execution store. What a witness needs is the three jobs SERDE-13, SERDE-21 and SERDE-22 put on
+ # the decode side and a bare parsed value cannot do alone: name the target on a wire null,
+ # refuse the nine cross-shape coercions, and permit the two representation-preserving ones.
+ #
+ # A frozen value in the phase-1 shape: `Data.define(:path, :target)`, `.new` and `.[]` private,
+ # a validating `.build`, `#with` through it. `path` is the JSON-Pointer-ish segment list
+ # (Strings for keys, Integers for indices), rendered by #pointer as RFC 6901 with its `~0`/`~1`
+ # escaping -- a standard with an unambiguous rule for a key containing `/`, where a dotted path
+ # needs a bespoke quoting rule the first time a key contains a `.`. `target` is the decode's
+ # target type as a NAME (a frozen String, or nil), never the witness object: a caller's class
+ # inside a Data member would join `==` and `hash`, and a name is all the message needs.
+ #
+ # `#target` exists because SERDE-13's conformance clause decodes "the literal null into a
+ # non-null DTO" and asserts the message names the DTO. A witness reached with nil calls
+ # `ctx.object!(nil)`, which knows only the shape it wanted, so without the target the message
+ # reads `expected Hash at /, got NilClass` and names Hash where the requirement asks for Pet.
+ # #error! therefore renders the ROOT frame as `expected Pet (Hash) at /, got NilClass` when a
+ # target is set, and the plain form everywhere else, because a nested frame's target IS its
+ # expected shape. `#at` carries the target unchanged so it stays available for a diagnostic at
+ # any depth without displacing the expectation at a nested path.
+ #
+ # Encoding takes no context (§7.3: "Ruby erases nothing -- every object carries its class"), so
+ # there is no DumpContext, and phase 2's CONTRACT already says the same thing mechanically: only
+ # #load takes a witness.
+ class DecodeContext < Data.define(:path, :target)
+ include Model
+
+ private_class_method :new, :[]
+
+ # The one context with no path and no target, shared: the common entry point allocates
+ # nothing.
+ empty = [] #: Array[String | Integer]
+ EMPTY_PATH = empty.freeze
+ private_constant :EMPTY_PATH
+
+ ROOT = new(path: EMPTY_PATH, target: nil)
+ private_constant :ROOT
+
+ # The validating factory every construction path goes through, `#at`'s and `#with`'s
+ # included. A caller descending into a document uses #at; this exists because Model#with
+ # routes through it and because a generator building a context for a nested decode is a
+ # legitimate caller.
+ #
+ # @param path [Array] the segments from the document root, copied and frozen
+ # @param target [String, nil] the decode's target type name, frozen
+ # @return [DecodeContext]
+ # @raise [Dexpace::InvalidArgumentError] naming the member, on a path that is not an Array
+ # of Strings and Integers or a target that is not a String
+ def self.build(path:, target:)
+ new(path: path, target: target)
+ end
+
+ # The entry point a decode starts from: an empty path and, when a witness is given, its name.
+ #
+ # The name is `Module#name` for a class or module (the ergonomic `serde.load(source, Pet)`
+ # route), a String as it is, and the CLASS name for anything else -- a combinator instance
+ # such as `List.of(Pet)` reports `Dexpace::Serde::List`; an anonymous class has no name and
+ # gets no target, so its message keeps the plain form. Set at the decode's entry point and
+ # never by a nil check inside #load: SERDE-20 requires a top-level null to decode to Null
+ # through `Tristate.of` and to nil through `Nullable.of`, and neither of those ever calls
+ # #object! on nil, so nothing raises and nothing needs an exemption.
+ #
+ # @param target [Module, String, Object, nil] the witness, or its name
+ # @return [DecodeContext] frozen; the shared instance when no target is given
+ def self.root(target: nil)
+ return ROOT if target.nil?
+
+ name =
+ case target
+ when ::Module then target.name
+ when ::String then target
+ else target.class.name
+ end
+ name.nil? ? ROOT : new(path: EMPTY_PATH, target: name)
+ end
+
+ def initialize(path:, target:)
+ segments = Model.required!("path", path)
+ unless segments.is_a?(::Array) && segments.all? { |segment| segment?(segment) }
+ raise InvalidArgumentError, "path must be an Array of String and Integer segments"
+ end
+ unless target.nil? || target.is_a?(::String)
+ raise InvalidArgumentError, "target must be a String or nil"
+ end
+
+ super(path: Model.own(segments), target: target.nil? ? nil : Model.frozen_string(target))
+ end
+
+ # A child context one segment deeper, carrying the same target. The receiver is untouched.
+ #
+ # @param segment [String, Integer] an object key or an array index
+ # @return [DecodeContext]
+ # @raise [Dexpace::InvalidArgumentError] on a segment of any other class
+ def at(segment)
+ unless segment?(segment)
+ raise InvalidArgumentError,
+ "segment must be a String or an Integer"
+ end
+
+ self.class.build(path: path + [segment], target: target)
+ end
+
+ # The path as an RFC 6901 JSON Pointer: `""` for the document root, otherwise one `/`-prefixed
+ # segment per level with `~` written `~0` and `/` written `~1`, in that order.
+ #
+ # @return [String]
+ def pointer
+ path.map { |segment| "/#{segment.to_s.gsub("~", "~0").gsub("/", "~1")}" }.join
+ end
+
+ # SERDE-21: a JSON object, or a shape failure.
+ #
+ # @param value [Object] the parsed value
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @return [Hash]
+ # @raise [Dexpace::Serde::DeserializationError]
+ def object!(value, key: nil)
+ return value if value.is_a?(::Hash)
+
+ error!(expected: "Hash", actual: value, key: key)
+ end
+
+ # SERDE-21: a JSON array, or a shape failure.
+ #
+ # @param value [Object] the parsed value
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @return [Array]
+ # @raise [Dexpace::Serde::DeserializationError]
+ def array!(value, key: nil)
+ return value if value.is_a?(::Array)
+
+ error!(expected: "Array", actual: value, key: key)
+ end
+
+ # SERDE-21/SERDE-22: a String, the empty String included; never a coerced scalar.
+ #
+ # @param value [Object] the parsed value
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @return [String]
+ # @raise [Dexpace::Serde::DeserializationError]
+ def string!(value, key: nil)
+ return value if value.is_a?(::String)
+
+ error!(expected: "String", actual: value, key: key)
+ end
+
+ # SERDE-21: an Integer; a Float (1.0 included), a numeric String and a boolean are all
+ # refused, because each is a lossy or cross-shape coercion the requirement names.
+ #
+ # @param value [Object] the parsed value
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @return [Integer]
+ # @raise [Dexpace::Serde::DeserializationError]
+ def integer!(value, key: nil)
+ return value if value.is_a?(::Integer)
+
+ error!(expected: "Integer", actual: value, key: key)
+ end
+
+ # SERDE-22: a Float, or an Integer WIDENED to one -- the one method here with a permission
+ # rather than a prohibition.
+ #
+ # @param value [Object] the parsed value
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @return [Float]
+ # @raise [Dexpace::Serde::DeserializationError]
+ def float!(value, key: nil)
+ return value if value.is_a?(::Float)
+ # SERDE-22: numeric widening of an integer into a floating-point target is representation-
+ # preserving and MUST be permitted, even though SERDE-21 forbids the reverse narrowing.
+ return value.to_f if value.is_a?(::Integer)
+
+ error!(expected: "Float", actual: value, key: key)
+ end
+
+ # SERDE-21: exactly `true` or `false`; `"true"`, `1` and `0` are refused.
+ #
+ # @param value [Object] the parsed value
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @return [Boolean]
+ # @raise [Dexpace::Serde::DeserializationError]
+ def boolean!(value, key: nil)
+ return value if value.equal?(true) || value.equal?(false)
+
+ error!(expected: "Boolean", actual: value, key: key)
+ end
+
+ # SERDE-13 for a witness that knows its own target: any non-nil value passes through and nil
+ # is refused with the caller's target named. `false` is a present value.
+ #
+ # @param value [Object] the parsed value
+ # @param target [String] the type name the message should carry
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @return [Object] `value`
+ # @raise [Dexpace::Serde::DeserializationError]
+ def present!(value, target, key: nil)
+ return value unless value.nil?
+
+ error!(expected: target, actual: value, key: key)
+ end
+
+ # The ONE raise site. The message form is fixed here -- `expected at ,
+ # got `, with the root frame naming the decode's target when one was set --
+ # which is what makes SERDE-13's "naming the target type" and SERDE-21's "surface as a
+ # deserialization failure" properties of this method rather than of every witness.
+ #
+ # @param expected [String] the shape or type the caller wanted
+ # @param actual [Object] the value it got; the message names its class
+ # @param key [String, Integer, nil] one segment appended to the path for the message only
+ # @raise [Dexpace::Serde::DeserializationError] always
+ def error!(expected:, actual:, key: nil)
+ frame = key.nil? ? self : at(key)
+ # SERDE-13: at the ROOT frame the expected shape is not the target -- a null decoded into
+ # Pet reports `expected Hash`, which names the wrong thing -- so the root frame names both.
+ # A nested frame's target IS its expected shape, so it renders the plain form.
+ wanted =
+ if frame.path.empty? && !frame.target.nil? && frame.target != expected
+ "#{frame.target} (#{expected})"
+ else
+ expected
+ end
+ where = frame.path.empty? ? "/" : frame.pointer
+ raise DeserializationError, "expected #{wanted} at #{where}, got #{actual.class}"
+ end
+
+ private
+
+ def segment?(segment)
+ segment.is_a?(::String) || segment.is_a?(::Integer)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb b/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb
new file mode 100644
index 0000000..b3f377d
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "../error/invalid_argument_error"
+require_relative "../http/response"
+require_relative "../http/body"
+require_relative "deserialization_error"
+require_relative "decode_context"
+require_relative "witness"
+
+module Dexpace
+ module Serde
+ # SERDE-27's response-decoding handler: a Dexpace::_ResponseHandler -- `#call(response) ->
+ # value` -- supplied INTO phase 3b's Dexpace::TypedResponse and never replacing it (charter
+ # boundary 7; 3b's `@state` memo and flip-only mutex are its, and this class holds no lock,
+ # because a handler runs OUTSIDE that lock by construction). The five clauses, one at a time:
+ #
+ # - "stream the response body directly through the deserializer" -- the handler hands `#load`
+ # the body's own `#source`, a Dexpace::IO::BufferedSource, and copies nothing; `#body_string`
+ # is never called. Which bodies that is true of, stated because `#source`'s default raises:
+ # Dexpace::ResponseBody (the transport's), Dexpace::ResponseLoggingBody (phase 5b's) and
+ # Dexpace::BufferBody (`Body.buffer`, and what Recovery.buffer_error_body produces) answer it;
+ # a BytesBody -- `Body.bytes`, `Body.string` -- does NOT, and a typed handler over one raises
+ # Dexpace::StreamError naming the class, indistinguishable from a genuine I/O failure. For an
+ # in-memory response, `Body.buffer` is the readable spelling; the handler adds no
+ # `respond_to?` fallback because HTTP-41 names `#source` as THE read handle.
+ # - "(without first materializing the whole body)" -- P7-1: NOT satisfied by the JSON adapter,
+ # which drains the source to EOF under Dexpace::IO.max_materialized_bytes; a body above that
+ # ceiling raises Dexpace::StreamError, an ::IOError, unwrapped. The handler's own behaviour is
+ # the strongest half available, and an adapter with a pull parser satisfies the clause with no
+ # change here because `#load` already takes the source.
+ # - "MUST consume and close the response on every path" -- one unguarded `ensure`, because
+ # `#close` is on Dexpace::Body's contract with a no-op default (P3-23) and Response#close is
+ # `body&.close`. This does not conflict with SERDE-3: SERDE-3 binds the CODEC and its subject
+ # is the caller's STREAM (`#load` closes nothing); this is the HANDLER and its subject is the
+ # RESPONSE, which it owns for the duration of the call. Two rules, two subjects. A close that
+ # raises propagates over the primary rather than attaching to it (HTTP-43, BODY-15) -- a loud
+ # close, never Dexpace.close_quietly.
+ # - "MUST surface a missing body (e.g. 204) as a serde exception naming the target type" -- a
+ # nil body AND an empty one, screened BEFORE `#load` with BufferedSource#eof?, a non-consuming
+ # probe, so the message can name the target only the handler knows; `#content_length` is -1
+ # for every unknown-length body and a parser's end-of-input message differs across versions,
+ # so neither is the test.
+ # - "MUST surface a codec/parse failure as a serde exception chaining the original while letting
+ # a genuine mid-stream I/O error propagate unwrapped" -- both the codec's, not the handler's:
+ # the adapter re-raises inside its rescue (Ruby sets #cause) and rescues its library's error
+ # family alone, which a Dexpace::StreamError is structurally outside. The handler rescues
+ # nothing and re-wraps nothing.
+ #
+ # A frozen Data in the phase-1 shape: `.new` and `.[]` private, `.build(serde:, witness:)` the
+ # validating factory, `Dexpace::Serde.witness!` at construction so a bad witness fails at
+ # handler construction rather than at first body access -- which matters because TypedResponse
+ # is lazy and a construction-time failure is the only one a caller sees before the wire.
+ class DecodingHandler < Data.define(:serde, :witness)
+ include Model
+
+ private_class_method :new, :[]
+
+ # The validating factory every construction path goes through.
+ #
+ # @param serde [Dexpace::_Codec] the codec, `#load(source, witness)` at least
+ # @param witness [Object] the target: a class answering .dexpace_load, or a combinator
+ # @return [DecodingHandler] frozen
+ # @raise [Dexpace::InvalidArgumentError] on a nil serde ("serde is required"), one that does
+ # not answer #load, or a witness that is not one (SERDE-8)
+ def self.build(serde:, witness:)
+ new(serde: serde, witness: witness)
+ end
+
+ def initialize(serde:, witness:)
+ Model.required!("serde", serde)
+ unless serde.respond_to?(:load)
+ raise InvalidArgumentError, "serde must answer #load(source, witness), got #{serde.class}"
+ end
+
+ super(serde: serde, witness: Dexpace::Serde.witness!(witness))
+ end
+
+ # The _ResponseHandler protocol: decodes the response's body through the codec into the
+ # witness's type, closing the response on every path (SERDE-27).
+ #
+ # @param response [Dexpace::Response]
+ # @return [Object] the witness's decode
+ # @raise [Dexpace::Serde::DeserializationError] on a missing or empty body (naming the
+ # target), or from the codec on malformed or mis-shaped content (chaining the library's
+ # error)
+ # @raise [Dexpace::StreamError] unwrapped, on a genuine I/O failure or a body over the ceiling
+ # @raise [Dexpace::InvalidArgumentError] when `response` is not a Dexpace::Response
+ def call(response)
+ unless response.is_a?(Response)
+ raise InvalidArgumentError, "response must be a Dexpace::Response, got #{response.class}"
+ end
+
+ begin
+ decode(response)
+ ensure
+ # SERDE-27: every path. The HANDLER's subject is the response; the codec's (SERDE-3) is
+ # the stream, and it closes nothing.
+ response.close
+ end
+ end
+
+ private
+
+ def decode(response)
+ body = response.body
+ raise missing_body if body.nil?
+
+ # BufferBody#source is a fresh view per call, so the one handle is obtained once and both
+ # probed and decoded.
+ source = body.source
+ raise missing_body if source.eof?
+
+ serde.load(source, witness)
+ end
+
+ # SERDE-27's "naming the target type": the name DecodeContext.root derives for the witness.
+ def missing_body
+ DeserializationError.new(
+ "no body to decode into #{target_name}: the response carried none (SERDE-27)",
+ )
+ end
+
+ # The witness's name as DecodeContext.root derives it, with a literal for the one witness
+ # that has none: an anonymous class gets no target (P7-70, so `#error!` keeps its plain form
+ # and no `#` reaches a message), and interpolating that nil here would read
+ # "decode into : the response" (review round 1, R1-4).
+ def target_name
+ DecodeContext.root(target: witness).target || "an anonymous witness"
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/instant.rb b/gems/dexpace-core/lib/dexpace/serde/instant.rb
new file mode 100644
index 0000000..ef412ec
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/instant.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require "time"
+
+require_relative "serialization_error"
+require_relative "decode_context"
+
+module Dexpace
+ module Serde
+ # The ISO-8601 instant witness (SERDE-24): `.dexpace_load` parses an ISO-8601 string into a
+ # ::Time and `.dexpace_dump` renders one, so the two halves of the round trip live together and
+ # a caller never matches independent conventions. Core's, not the adapter's, because a witness
+ # is codec-agnostic by construction and a second codec would otherwise write a second one; the
+ # DEFAULT wiring of it as the encoder for ::Time stays the adapter's (design §3.4: "the
+ # adapter's default encoder configuration renders date and time values as ISO-8601 strings"),
+ # which is why ::Time is deliberately absent from core's scalar table and `List.of(::Time)` is
+ # refused.
+ #
+ # `Time.iso8601` and `Time#iso8601` are `time`'s -- a default gem across the whole supported
+ # range and on the require allowlist -- and are not what Dexpace/NoTimeParse bans (verified
+ # fact 10). Phase 5a's Dexpace::HTTPDate is RFC 1123, a different grammar, and is not consumed.
+ #
+ # THE PRECISION DOMAIN (P7-8). The encoder emits `#iso8601(6)` -- microseconds, the resolution
+ # the overwhelming majority of HTTP APIs use -- and `Time#iso8601(n)` TRUNCATES rather than
+ # rounds, verified on every supported Ruby. So the round trip SERDE-24 requires holds exactly
+ # for any Time whose `subsec` is an exact multiple of one microsecond: every Time this SDK
+ # constructs (`Time.utc(...)`, `Time.at(sec, usec, :usec)`) and every Time this witness decodes.
+ # Outside that domain the encoding truncates and the round trip is lossy, including the
+ # ordinary-looking `Time.new(2026, 9, 10, 12, 0, 0.123456, "+02:00")`: its Float second is
+ # stored as the exact rational 8895942329546431/72057594037927936 (0.12345599999...) and
+ # renders as `...00.123455+02:00`, one microsecond low. The port does not round instead --
+ # `Time#iso8601` is `time`'s, and re-implementing its formatter to round would be a second date
+ # formatter beside HTTPDate.
+ #
+ # The decoder is strict the way SERDE-13/SERDE-21 want: `Time.iso8601` rejects `"2026-09-10"`,
+ # `"2026-09-10 12:00:00"`, `""` and `"not a time"`, and each becomes a DeserializationError
+ # naming `Time (ISO-8601)` at the field's path.
+ module Instant
+ extend self
+
+ # What the shape failure names (SERDE-13): the target, and the grammar it wanted.
+ EXPECTED = "Time (ISO-8601)"
+ private_constant :EXPECTED
+
+ # The witness protocol: an ISO-8601 string to a ::Time, or a shape failure naming Time.
+ #
+ # @param parsed [Object] the parsed value; anything but a well-formed ISO-8601 String fails
+ # @param ctx [Dexpace::Serde::DecodeContext]
+ # @return [Time]
+ # @raise [Dexpace::Serde::DeserializationError]
+ def dexpace_load(parsed, ctx)
+ ctx.error!(expected: EXPECTED, actual: parsed) unless parsed.is_a?(::String)
+
+ ::Time.iso8601(parsed)
+ rescue ::ArgumentError
+ ctx.error!(expected: EXPECTED, actual: parsed)
+ end
+
+ # The encoder half, what the adapter installs for ::Time: `time.iso8601(6)`, within the
+ # precision domain stated above.
+ #
+ # @param time [Time]
+ # @return [String] ISO-8601 with microseconds and the Time's own offset (`Z` for UTC)
+ # @raise [Dexpace::Serde::SerializationError] when `time` is not a ::Time
+ def dexpace_dump(time)
+ unless time.is_a?(::Time)
+ raise SerializationError,
+ "Instant encodes a Time, got #{time.class}"
+ end
+
+ time.iso8601(6)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/list.rb b/gems/dexpace-core/lib/dexpace/serde/list.rb
new file mode 100644
index 0000000..e58f8b3
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/list.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "scalars"
+require_relative "decode_context"
+
+module Dexpace
+ module Serde
+ # The Array combinator (SERDE-6): a witness for `Array[element]`, built BY VALUE from a concrete
+ # element witness -- `List.of(Pet)`, `List.of(String)`, `List.of(List.of(Integer))` -- so a
+ # parametric target is stated once, as data, with no reflective reconstruction anywhere. A
+ # combinator is itself a witness, so combinators nest.
+ #
+ # Construction resolves the element through the scalar table and `Dexpace::Serde.witness!`,
+ # so `List.of(nil)` and `List.of(Object.new)` raise at CONSTRUCTION with an actionable message
+ # -- SERDE-8's "reject construction with no type argument", implemented. Its "unresolved type
+ # variable" half is unreachable rather than emulated: a combinator cannot exist without a
+ # concrete element (`serde/ffc92673`), which is also why `.witness!` fails earlier than the
+ # reference's binder-resolution failure. A frozen Data in the phase-1 shape: `.new` and `.[]`
+ # private, `.build` the validating factory `.of` and `#with` both route through.
+ class List < Data.define(:element)
+ include Model
+
+ private_class_method :new, :[]
+
+ # The ergonomic constructor design §7.3 names.
+ #
+ # @param element [Module, Object] the element witness, or String / Integer / Float / BOOLEAN
+ # @return [List]
+ # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8)
+ def self.of(element) = build(element: element)
+
+ # The validating factory every construction path goes through.
+ #
+ # @param element [Module, Object] the element witness
+ # @return [List]
+ # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8)
+ def self.build(element:)
+ new(element: element)
+ end
+
+ def initialize(element:)
+ super(element: Scalars.resolve(element))
+ end
+
+ # The witness protocol: an Array whose every element decoded through the element witness at
+ # its own index, so an element's shape failure names `/3/name` and not the container.
+ #
+ # @param parsed [Object] the parsed value; anything but an Array is a shape failure
+ # @param ctx [Dexpace::Serde::DecodeContext]
+ # @return [Array] fresh, never the parsed Array
+ # @raise [Dexpace::Serde::DeserializationError]
+ def dexpace_load(parsed, ctx)
+ ctx.array!(parsed).each_with_index.map do |item, index|
+ element.dexpace_load(item, ctx.at(index))
+ end
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/map.rb b/gems/dexpace-core/lib/dexpace/serde/map.rb
new file mode 100644
index 0000000..481b1dee
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/map.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "scalars"
+require_relative "decode_context"
+
+module Dexpace
+ module Serde
+ # The Hash combinator (SERDE-6): a witness for `Hash[key, value]`, built by value from a key
+ # witness and a value witness -- `Map.of(String, Pet)`. The key witness is REQUIRED rather than
+ # assumed to be String: JSON's keys always are, but a codec whose keys are not inherits the
+ # combinator unchanged, and a key of the wrong shape is a shape failure like any other. Both
+ # arguments resolve through the scalar table and `Dexpace::Serde.witness!` at construction
+ # (SERDE-8). A frozen Data in the phase-1 shape.
+ class Map < Data.define(:key, :value)
+ include Model
+
+ private_class_method :new, :[]
+
+ # The ergonomic constructor design §7.3 names.
+ #
+ # @param key [Module, Object] the key witness, or a scalar class
+ # @param value [Module, Object] the value witness, or a scalar class
+ # @return [Map]
+ # @raise [Dexpace::InvalidArgumentError] when either is not a witness (SERDE-8)
+ def self.of(key, value) = build(key: key, value: value)
+
+ # The validating factory every construction path goes through.
+ #
+ # @param key [Module, Object] the key witness
+ # @param value [Module, Object] the value witness
+ # @return [Map]
+ # @raise [Dexpace::InvalidArgumentError] when either is not a witness (SERDE-8)
+ def self.build(key:, value:)
+ new(key: key, value: value)
+ end
+
+ def initialize(key:, value:)
+ super(key: Scalars.resolve(key), value: Scalars.resolve(value))
+ end
+
+ # The witness protocol: a Hash whose every entry has its key and its value decoded through
+ # the two witnesses at the entry's own path.
+ #
+ # @param parsed [Object] the parsed value; anything but a Hash is a shape failure
+ # @param ctx [Dexpace::Serde::DecodeContext]
+ # @return [Hash] fresh, never the parsed Hash
+ # @raise [Dexpace::Serde::DeserializationError]
+ def dexpace_load(parsed, ctx)
+ ctx.object!(parsed).to_h do |raw_key, raw_value|
+ frame = ctx.at(raw_key.is_a?(::Integer) ? raw_key : raw_key.to_s)
+ [key.dexpace_load(raw_key, frame), value.dexpace_load(raw_value, frame)]
+ end
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/native.rb b/gems/dexpace-core/lib/dexpace/serde/native.rb
new file mode 100644
index 0000000..5816023
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/native.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "../error/invalid_argument_error"
+require_relative "serialization_error"
+require_relative "witness"
+
+module Dexpace
+ module Serde
+ # The value a `#dexpace_dump` returns for a field that must be OMITTED from the enclosing
+ # object: Tristate::ABSENT's dump, and what a hand-written model returns for the same effect.
+ # Native's walk drops a Hash entry whose walked value is this sentinel (SERDE-15), writes a wire
+ # null for it inside an Array and at the top level (SERDE-20), and nothing else ever sees it.
+ # A frozen singleton of a class a caller cannot name, with a stable textual form for the reason
+ # SERDE-30 gives ABSENT and NULL theirs.
+ class Omit
+ # @return [String] "Omit"
+ def to_s = "Omit"
+ alias inspect to_s
+ end
+ private_constant :Omit
+
+ # The frozen omission sentinel (see Omit).
+ OMIT = Omit.new.freeze
+
+ # The encode walk (P7-9): turns any value into codec-native Ruby -- Hash, Array, String,
+ # Integer, Float, true, false, nil -- in the one place where SERDE-15's key omission, SERDE-20's
+ # three degradations and SERDE-9's loud failure on an unencodable value all live, so a codec
+ # adapter inherits them by calling `.of` and no model has to remember them.
+ #
+ # Design §7.3 puts the Absent-key omission in each model's own `#dexpace_dump`. It lives here
+ # instead because SERDE-19 is a MUST whose named failure -- "absent this wiring, Absent and Null
+ # become indistinguishable on the wire" -- is exactly what a per-model convention produces when
+ # one model forgets, silently, in a PATCH. In the walk it is structural, which is the word §7.3
+ # itself uses for what SERDE-19 needs; a model that omits its own Absent keys still works, the
+ # walk simply has nothing to drop. It is also why `Native` and `OMIT` are public: a second codec
+ # adapter (dexpace-serde-oj, post-v1) calls `.of` and inherits tri-state encoding, which is
+ # design §3.4's "no second code path in core" made concrete.
+ #
+ # The seven rules, in order:
+ #
+ # 1. nil, true, false, an Integer and a Float pass through; a String passes through with no
+ # retag (a mutable one is copied and frozen, XCUT-15). A BINARY-tagged String is handed to
+ # the generator as it is: json refuses one holding invalid UTF-8 (a SerializationError here)
+ # and warns on one holding valid UTF-8, so text is encoded as UTF-8 before it reaches a
+ # codec.
+ # 2. Anything answering `#dexpace_dump` (DUMP_METHOD) is replaced by its dump and RE-WALKED --
+ # the model case, the Tristate case and the hand-written case are one branch, and it wins
+ # over an `encoders:` entry for the same class.
+ # 3. A Hash: every value walked, an entry whose walked value is OMIT dropped (SERDE-15), keys
+ # coerced from String or Symbol to String and anything else refused -- a key is a name, and
+ # an Integer or an object silently stringified is the kind of quiet mistake this walk exists
+ # to refuse.
+ # 4. An Array: every element walked, an element that walks to OMIT written as nil (SERDE-20).
+ # 5. At the top level, a value that walks to OMIT is nil (SERDE-20: "emit a wire null for both
+ # Absent and Null rather than throwing").
+ # 6. A class present in `encoders:` -- by exact class first, then the first entry the value
+ # `is_a?`, in the table's order -- is replaced by `encoders[klass].call(value)` and
+ # re-walked. This is the only hook, and it is what carries the adapter's ISO-8601 default
+ # (design §3.4) without core naming ::Time as a policy: core ships the table EMPTY.
+ # 7. Anything else -- a Symbol, a Time with no encoder, an arbitrary object -- raises
+ # SerializationError NAMING THE CLASS, the loud failure verified fact 3 shows ::JSON.generate
+ # will not give (it returns the object's `#inspect` as a JSON string).
+ #
+ # The walk returns fresh collections and never aliases a caller's (XCUT-15). A cyclic object
+ # graph and an encoder that returns a value of its own class both recurse without bound; both
+ # are caller mistakes, and neither is one a codec's own nesting cap can see because the walk
+ # runs before the generator does.
+ module Native
+ extend self
+
+ # Core's default: no encoder at all.
+ none = {} #: Hash[Module, untyped]
+ NO_ENCODERS = none.freeze
+ private_constant :NO_ENCODERS
+
+ # Walks `value` into codec-native Ruby.
+ #
+ # @param value [Object] anything: a model, a Tristate, a Hash, an Array, a scalar
+ # @param encoders [Hash{Class => #call}] the one hook, empty by default
+ # @return [Hash, Array, String, Integer, Float, true, false, nil]
+ # @raise [Dexpace::Serde::SerializationError] on a value no rule accepts, naming its class
+ # @raise [Dexpace::InvalidArgumentError] on an `encoders:` that is not a Hash of Class to
+ # callable
+ def of(value, encoders: NO_ENCODERS)
+ table = encoders!(encoders)
+ walked = walk(value, table)
+ walked.equal?(OMIT) ? nil : walked
+ end
+
+ private
+
+ def encoders!(encoders)
+ unless encoders.is_a?(::Hash) && encoders.all? do |k, v|
+ k.is_a?(::Module) && v.respond_to?(:call)
+ end
+ raise InvalidArgumentError,
+ "encoders must be a Hash of Class to #call, got #{encoders.class}"
+ end
+
+ encoders
+ end
+
+ def walk(value, encoders)
+ return value if native_scalar?(value) || value.equal?(OMIT)
+ return Model.frozen_string(value) if value.is_a?(::String)
+ return walk(value.public_send(DUMP_METHOD), encoders) if value.respond_to?(DUMP_METHOD)
+ return walk_hash(value, encoders) if value.is_a?(::Hash)
+ return walk_array(value, encoders) if value.is_a?(::Array)
+
+ walk_encoded(value, encoders)
+ end
+
+ def native_scalar?(value)
+ value.nil? || value.equal?(true) || value.equal?(false) ||
+ value.is_a?(::Integer) || value.is_a?(::Float)
+ end
+
+ def walk_hash(hash, encoders)
+ out = {} #: Hash[String, untyped]
+ hash.each_with_object(out) do |(key, raw), acc|
+ walked = walk(raw, encoders)
+ acc[key!(key)] = walked unless walked.equal?(OMIT)
+ end
+ end
+
+ def walk_array(array, encoders)
+ array.map do |raw|
+ walked = walk(raw, encoders)
+ walked.equal?(OMIT) ? nil : walked
+ end
+ end
+
+ def walk_encoded(value, encoders)
+ encoder = encoders[value.class] || encoders.find { |klass, _| value.is_a?(klass) }&.last
+ if encoder.nil?
+ raise SerializationError,
+ "#{value.class} is not a codec-native value: it answers no ##{DUMP_METHOD} and " \
+ "no encoder is configured for it"
+ end
+
+ walk(encoder.call(value), encoders)
+ end
+
+ def key!(key)
+ case key
+ when ::String then Model.frozen_string(key)
+ when ::Symbol then key.name
+ else raise SerializationError, "a Hash key must be a String or a Symbol, got #{key.class}"
+ end
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/nullable.rb b/gems/dexpace-core/lib/dexpace/serde/nullable.rb
new file mode 100644
index 0000000..0cb0837
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/nullable.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "scalars"
+require_relative "decode_context"
+
+module Dexpace
+ module Serde
+ # The nullable combinator (SERDE-6): a witness that accepts a wire null where its element
+ # witness would not -- `Nullable.of(Pet)` decodes `null` to nil and anything else through `Pet`.
+ #
+ # It is the witness-aware half of SERDE-13's repair. `#load` screens nothing for nil itself,
+ # because THIS witness and `Tristate.of` legitimately want a top-level null (SERDE-20), and
+ # neither ever calls `ctx.object!` on nil -- so a null into a non-null target still fails
+ # through the element witness, naming the target, with no exemption anywhere. Resolves its
+ # element at construction (SERDE-8); a frozen Data in the phase-1 shape.
+ class Nullable < Data.define(:element)
+ include Model
+
+ private_class_method :new, :[]
+
+ # The ergonomic constructor design §7.3 names.
+ #
+ # @param element [Module, Object] the element witness, or a scalar class
+ # @return [Nullable]
+ # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8)
+ def self.of(element) = build(element: element)
+
+ # The validating factory every construction path goes through.
+ #
+ # @param element [Module, Object] the element witness
+ # @return [Nullable]
+ # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8)
+ def self.build(element:)
+ new(element: element)
+ end
+
+ def initialize(element:)
+ super(element: Scalars.resolve(element))
+ end
+
+ # The witness protocol: nil for a wire null, the element's decode otherwise.
+ #
+ # @param parsed [Object]
+ # @param ctx [Dexpace::Serde::DecodeContext]
+ # @return [Object, nil]
+ # @raise [Dexpace::Serde::DeserializationError] from the element witness
+ def dexpace_load(parsed, ctx)
+ return nil if parsed.nil?
+
+ element.dexpace_load(parsed, ctx)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/scalars.rb b/gems/dexpace-core/lib/dexpace/serde/scalars.rb
new file mode 100644
index 0000000..4ee3cf0
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/scalars.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "witness"
+require_relative "decode_context"
+
+module Dexpace
+ module Serde
+ # The scalar witnesses behind the ergonomic spellings design §7.3 uses verbatim --
+ # `List.of(String)`, `Map.of(String, Pet)`, `Tristate.of(Float)` -- so a combinator's argument
+ # is uniformly "a witness" while a bare class object still works where a scalar is meant. A
+ # private_constant with a sig/ mirror, consulted by every combinator's constructor; not public
+ # API. The one public name it needs, BOOLEAN, is defined below it on Serde itself.
+ module Scalars
+ extend self
+
+ # One scalar witness: a name for the diagnostics and the DecodeContext check it delegates to.
+ # Frozen singletons, so two `List.of(String)` share one element and compare equal.
+ class Scalar
+ # @param name [String] the textual form
+ # @param check [Proc] `(parsed, ctx) -> value`, one of DecodeContext's `!` methods
+ def initialize(name, check)
+ @name = name
+ @check = check
+ freeze
+ end
+
+ # The witness protocol: delegates the shape check to the context (SERDE-21, SERDE-22).
+ #
+ # @param parsed [Object]
+ # @param ctx [Dexpace::Serde::DecodeContext]
+ # @return [Object] the checked value
+ def dexpace_load(parsed, ctx) = @check.call(parsed, ctx)
+
+ # @return [String] the scalar's name
+ def to_s = @name
+ alias inspect to_s
+ end
+
+ # The String witness: DecodeContext#string!.
+ STRING = Scalar.new("String", ->(parsed, ctx) { ctx.string!(parsed) })
+ # The Integer witness: DecodeContext#integer!.
+ INTEGER = Scalar.new("Integer", ->(parsed, ctx) { ctx.integer!(parsed) })
+ # The Float witness: DecodeContext#float!, with SERDE-22's widening.
+ FLOAT = Scalar.new("Float", ->(parsed, ctx) { ctx.float!(parsed) })
+ # The boolean witness, published on Serde as BOOLEAN: DecodeContext#boolean!.
+ BOOLEAN = Scalar.new("Boolean", ->(parsed, ctx) { ctx.boolean!(parsed) })
+
+ # The class-to-witness table. ::Time is deliberately absent: the ISO-8601 wiring is the
+ # adapter's per design §3.4, and a core mapping would make it every codec's default. Booleans
+ # are not keyed on TrueClass/FalseClass either -- that is what BOOLEAN is for.
+ TABLE = { ::String => STRING, ::Integer => INTEGER, ::Float => FLOAT }.freeze
+
+ # The one place the ergonomic spelling and the protocol meet: a class in the table resolves
+ # to its scalar witness, and anything else must be a witness in its own right (SERDE-8's
+ # fail-fast, at construction).
+ #
+ # @param witness [Module, Object] a table class or a witness
+ # @return [Object] a witness
+ # @raise [Dexpace::InvalidArgumentError] when it is neither
+ def resolve(witness)
+ TABLE.fetch(witness) { Dexpace::Serde.witness!(witness) }
+ end
+ end
+ private_constant :Scalars
+
+ # The boolean scalar witness -- `List.of(Dexpace::Serde::BOOLEAN)` -- a NAMED witness rather
+ # than two class keys, because Ruby has no Boolean class to key on and `List.of(TrueClass)`
+ # would read as a list of `true`s. Accepts exactly `true` and `false` (SERDE-21).
+ BOOLEAN = Scalars::BOOLEAN
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb b/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb
new file mode 100644
index 0000000..26ce79d
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "../error/invalid_argument_error"
+require_relative "../error/protocol_error"
+require_relative "../http/response"
+require_relative "../recovery"
+require_relative "../registry"
+require_relative "deserialization_error"
+require_relative "decode_context"
+require_relative "decoding_handler"
+
+module Dexpace
+ module Serde
+ # SERDE-28's status-aware response handler: a Dexpace::_ResponseHandler supplied into phase
+ # 3b's TypedResponse, dispatching on the status in three branches --
+ #
+ # 2xx -> the DecodingHandler built from the same serde and witness
+ # 4xx / 5xx -> raise factory.call(Recovery.buffer_error_body(response))
+ # anything else (1xx, 3xx, 304) -> close the response; raise DeserializationError, "
+ # ..."
+ #
+ # The 2xx branch DELEGATES rather than re-implementing, so SERDE-27's five clauses have exactly
+ # one implementation and "decode the body only on a 2xx status" is a branch, not a second
+ # decoder. The 4xx/5xx branch uses phase 4b's objects and adds none: Recovery.buffer_error_body
+ # is the ONE buffering call site (RECOV-16, BODY-30, HTTP-52 -- it already returns the response
+ # unchanged when the body is nil, so no nil check here), and its Body.buffer_bounded closes the
+ # live body in its own `ensure`, so this branch adds NO close of its own -- a second one would
+ # be a double close of an object 4b already released, which is why the suite counts raw closes.
+ # `factory:` defaults to core's ProtocolError.for as one frozen lambda, the exact shape
+ # Recovery::ErrorMappingStep uses, so a generated SDK substitutes its typed errors here with the
+ # keyword it already knows from the recovery chain and SERDE-28's "the MAPPED HTTP-error
+ # exception" is RECOV-15's object rather than a second one; `.for` and not `.for_or_nil`,
+ # because the branch has already established `status.error?`. The error is raised `cause: nil`:
+ # the factory CONSTRUCTED it, and a bare `raise` inside a caller's rescue would chain the
+ # caller's in-flight exception onto it (`pipeline/7ce4431d`).
+ #
+ # The third branch is where SERDE-28 is easiest to get half-right: the message "leads with the
+ # status code and preserves conditional/redirect context (ETag / Location)", so it copies the
+ # RAW `ETag` and `Location` header values and parses neither -- running a malformed server ETag
+ # through HTTP-48's validating helper inside an error path would turn a diagnostic into a second
+ # failure. The chapter's conformance clause also names "a non-canonical 599", which is a 5xx
+ # and therefore the SECOND branch; it is called out here because a reader skimming
+ # "non-canonical" will file it under the third.
+ #
+ # A frozen Data in the phase-1 shape. Its members are the three build keywords, so `#with`
+ # re-derives the whole thing through `.build`; the DecodingHandler for the 2xx branch is built
+ # once in `#initialize` -- which is also where the serde and the witness are validated.
+ class StatusAwareHandler < Data.define(:serde, :witness, :factory)
+ include Model
+
+ # Core's factory as a callable: one frozen object, the same proc type a caller's factory
+ # has (ErrorMappingStep's shape). Private, because P7-2 fixes the public constants.
+ DEFAULT_FACTORY = ->(response) { ProtocolError.for(response) }.freeze
+ private_constant :DEFAULT_FACTORY
+
+ private_class_method :new, :[]
+
+ # The validating factory every construction path goes through.
+ #
+ # @param serde [Dexpace::_Codec] the codec, `#load(source, witness)` at least
+ # @param witness [Object] the SUCCESS type's witness: a class answering .dexpace_load, or a
+ # combinator; the error payload never reaches it
+ # @param factory [#call] `#call(buffered_response) -> Exception`, ProtocolError.for by default
+ # @return [StatusAwareHandler] frozen
+ # @raise [Dexpace::InvalidArgumentError] on a nil serde, a non-witness, or a nil or
+ # non-callable factory
+ def self.build(serde:, witness:, factory: DEFAULT_FACTORY)
+ new(serde: serde, witness: witness, factory: factory)
+ end
+
+ def initialize(serde:, witness:, factory:)
+ Model.required!("factory", factory)
+ unless Registry.callable?(factory, arity: 1)
+ raise InvalidArgumentError, "factory must respond to #call(response)"
+ end
+
+ # Validates the serde and the witness (SERDE-8, at construction) and is the one decoder.
+ @decoding = DecodingHandler.build(serde: serde, witness: witness)
+ super(serde: @decoding.serde, witness: @decoding.witness, factory: factory)
+ end
+
+ # The _ResponseHandler protocol, dispatched on the status (SERDE-28).
+ #
+ # @param response [Dexpace::Response]
+ # @return [Object] the witness's decode, for a 2xx
+ # @raise [Exception] the factory's error, for a 4xx or 5xx, carrying the buffered response
+ # @raise [Dexpace::Serde::DeserializationError] for a 1xx or a 3xx, leading with the code, or
+ # from the decoder on a 2xx with a missing, empty or malformed body
+ # @raise [Dexpace::InvalidArgumentError] when `response` is not a Dexpace::Response, or the
+ # factory returned something that is not an Exception
+ def call(response)
+ unless response.is_a?(Response)
+ raise InvalidArgumentError, "response must be a Dexpace::Response, got #{response.class}"
+ end
+ return @decoding.call(response) if response.success?
+ return raise_mapped(response) if response.error?
+
+ raise_unhandled(response)
+ end
+
+ private
+
+ # 4xx/5xx: buffer (which closes the live response), map, raise. No second close.
+ def raise_mapped(response)
+ error = factory.call(Recovery.buffer_error_body(response))
+ unless error.is_a?(::Exception)
+ raise InvalidArgumentError,
+ "factory must return an Exception for status #{response.status.code}, " \
+ "got #{error.class}"
+ end
+
+ raise error, cause: nil
+ end
+
+ # 1xx or 3xx: close, then raise with the code first and the raw conditional/redirect headers.
+ def raise_unhandled(response)
+ message = unhandled_message(response)
+ response.close
+ raise DeserializationError, message
+ end
+
+ def unhandled_message(response)
+ status = response.status
+ headers = response.headers
+ context = %w[etag location].filter_map do |name|
+ values = headers[name]
+ "#{name}: #{values.join(", ")}" unless values.nil? || values.empty?
+ end
+ lead = "#{status.code} #{status.canonical_name}".rstrip
+ ["#{lead}: not decoded into #{target_name}, only a 2xx body is (SERDE-28)", *context]
+ .join("; ")
+ end
+
+ # As DecodingHandler#target_name: the derived name, or a literal for an anonymous witness,
+ # which DecodeContext.root gives no target (P7-70; review round 1, R1-4).
+ def target_name
+ DecodeContext.root(target: witness).target || "an anonymous witness"
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/tristate.rb b/gems/dexpace-core/lib/dexpace/serde/tristate.rb
new file mode 100644
index 0000000..dd6da2a
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/tristate.rb
@@ -0,0 +1,226 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../model"
+require_relative "../error/invalid_argument_error"
+require_relative "native"
+require_relative "scalars"
+require_relative "decode_context"
+
+module Dexpace
+ module Serde
+ # The three-state PATCH type (SERDE-14–SERDE-20, SERDE-30): Absent (the key is missing), Null
+ # (the key is present with an explicit null) and Present (the key carries a value). A module
+ # included by all three values -- exactly Outcome's shape (4b) and Context's (4a) -- so
+ # `v.is_a?(Dexpace::Serde::Tristate)` is one type test and the RBS union has a name.
+ #
+ # SERDE-14's illegal fourth state, Present-of-null, is unrepresentable on BOTH paths: `Present`
+ # validates in `#initialize`, so `.build`, the private `.new` and `.[]`, and the
+ # send-past-private hole P8 records all refuse nil; and `Dexpace::Model#with` routes a
+ # derivation through `.build` on every supported Ruby (`data-modeling/83610619`: Data#with skips
+ # an `initialize` override on 3.2), so `Tristate.present(1).with(value: nil)` raises too. The
+ # covariance clause is satisfied by the language (design §11.15) and has no code.
+ #
+ # `.of(element)` and `.from_nullable(value)` are two different things with two different names,
+ # deliberately: the first is the decode-side combinator design §7.3 names, the second is
+ # SERDE-18's "nullable-to-(present|null) mapper that can never yield Absent". One `.of` doing
+ # both would be the API-design mistake SERDE-18's own conformance clause exists to catch.
+ #
+ # `#dexpace_dump` is where the type meets Native's walk: OMIT for Absent (the walk drops the
+ # key, SERDE-15), nil for Null, the inner value for Present (re-walked, so a nested model or a
+ # nested Tristate is handled by the same recursion). The two sentinels override `#to_s` and
+ # `#inspect` (SERDE-30, taken) because Ruby's default `#inspect` renders an object id, which
+ # would make a log line or a test failure differ between runs; the frozen singletons are
+ # Ractor-shareable as a free side effect that no claim rests on.
+ module Tristate
+ # @return [Boolean] whether this is the Absent value
+ def absent? = false
+
+ # @return [Boolean] whether this is the Null value
+ def null? = false
+
+ # @return [Boolean] whether this is a Present value
+ def present? = false
+
+ # SERDE-18's value-or-null accessor: the inner value for Present, nil otherwise.
+ #
+ # @return [Object, nil]
+ def value_or_nil = nil
+
+ # SERDE-18's three-way fold.
+ #
+ # @param on_absent [#call] called with no argument for Absent
+ # @param on_null [#call] called with no argument for Null
+ # @param on_present [#call] called with the inner value for Present
+ # @return [Object] whatever the matching callable returned
+ def fold(on_absent:, on_null:, on_present:)
+ if present?
+ on_present.call(value_or_nil)
+ elsif null?
+ on_null.call
+ else
+ on_absent.call
+ end
+ end
+
+ # The Absent sentinel's class: private, so a caller reaches the value through ABSENT alone.
+ class Absent
+ include Tristate
+
+ # @return [true]
+ def absent? = true
+
+ # SERDE-15: an Absent field's key is omitted; the walk drops OMIT.
+ #
+ # @return [Dexpace::Serde::OMIT]
+ def dexpace_dump = OMIT
+
+ # @return [String] "Absent" (SERDE-30)
+ def to_s = "Absent"
+ alias inspect to_s
+ end
+
+ # The Null sentinel's class: private, so a caller reaches the value through NULL alone.
+ class Null
+ include Tristate
+
+ # @return [true]
+ def null? = true
+
+ # SERDE-15: a Null field emits its key with a wire null.
+ #
+ # @return [nil]
+ def dexpace_dump = nil
+
+ # @return [String] "Null" (SERDE-30)
+ def to_s = "Null"
+ alias inspect to_s
+ end
+ private_constant :Absent, :Null
+
+ # The Absent value: the key is missing.
+ ABSENT = Absent.new.freeze
+
+ # The Null value: the key is present with an explicit null.
+ NULL = Null.new.freeze
+
+ # A Present value, bounded to non-null (SERDE-14). A frozen Data in the phase-1 shape whose
+ # equality is the inner value's.
+ class Present < Data.define(:value)
+ include Model
+ include Tristate
+
+ private_class_method :new, :[]
+
+ # The validating factory every construction path goes through (`#with` included).
+ #
+ # @param value [Object] non-nil
+ # @return [Present]
+ # @raise [Dexpace::InvalidArgumentError] "value is required", on nil (SERDE-14, SEAM-29)
+ def self.build(value:)
+ new(value: value)
+ end
+
+ def initialize(value:)
+ Model.required!("value", value)
+ super
+ end
+
+ # @return [true]
+ def present? = true
+
+ # @return [Object] the inner value
+ def value_or_nil = value
+
+ # SERDE-15: a Present field emits its key with the encoded inner value; Native re-walks it.
+ #
+ # @return [Object] the inner value
+ def dexpace_dump = value
+ end
+
+ # The decode-side combinator `.of` returns: a witness for a tri-state field. Private, because
+ # `.of` is the whole of its public surface; a frozen Data by value from its element witness.
+ class Combinator < Data.define(:element)
+ include Model
+
+ private_class_method :new, :[]
+
+ # @param element [Module, Object] the element witness, or a scalar class the table knows
+ # @return [Combinator]
+ def self.build(element:)
+ new(element: element)
+ end
+
+ def initialize(element:)
+ super(element: Scalars.resolve(element))
+ end
+
+ # SERDE-20's top-level case: the protocol entry point sees only the value, so a null is
+ # Null and anything else is Present of the element's decode.
+ #
+ # @param parsed [Object]
+ # @param ctx [Dexpace::Serde::DecodeContext]
+ # @return [Tristate]
+ def dexpace_load(parsed, ctx)
+ return NULL if parsed.nil?
+
+ Present.build(value: element.dexpace_load(parsed, ctx))
+ end
+
+ # SERDE-16/SERDE-17's in-object case, three lines because of verified fact 6: the enclosing
+ # Hash answers `key?` directly, so a missing key is Absent, a present null is Null and a
+ # present value is Present of the element's decode at the key's own path. No field-default
+ # machinery, none emulated.
+ #
+ # @param hash [Hash] the enclosing parsed object
+ # @param key [String] the field's key
+ # @param ctx [Dexpace::Serde::DecodeContext] the enclosing object's context
+ # @return [Tristate]
+ def dexpace_load_field(hash, key, ctx)
+ entries = ctx.object!(hash)
+ return ABSENT unless entries.key?(key)
+
+ value = entries[key]
+ return NULL if value.nil?
+
+ Present.build(value: element.dexpace_load(value, ctx.at(key)))
+ end
+ end
+ private_constant :Combinator
+
+ class << self
+ # SERDE-18: the Absent factory.
+ #
+ # @return [Tristate] ABSENT
+ def absent = ABSENT
+
+ # SERDE-18: the explicit-null factory.
+ #
+ # @return [Tristate] NULL
+ def null = NULL
+
+ # SERDE-18: the Present factory, refusing nil.
+ #
+ # @param value [Object] non-nil
+ # @return [Tristate::Present]
+ # @raise [Dexpace::InvalidArgumentError] on nil (SERDE-14)
+ def present(value) = Present.build(value: value)
+
+ # SERDE-18's nullable mapper: Present for a non-nil value, Null for nil, and NEVER Absent.
+ #
+ # @param value [Object, nil]
+ # @return [Tristate]
+ def from_nullable(value) = value.nil? ? NULL : Present.build(value: value)
+
+ # The decode-side combinator (design §7.3): a witness for a tri-state field of `element`,
+ # with `#dexpace_load_field(hash, key, ctx)` for the in-object case and the protocol's
+ # `#dexpace_load(parsed, ctx)` for the top-level one.
+ #
+ # @param element [Module, Object] the element witness, or a scalar class
+ # @return [Object] a witness
+ # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8)
+ def of(element) = Combinator.build(element: element)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/lib/dexpace/serde/witness.rb b/gems/dexpace-core/lib/dexpace/serde/witness.rb
new file mode 100644
index 0000000..b0d8091
--- /dev/null
+++ b/gems/dexpace-core/lib/dexpace/serde/witness.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../serde"
+require_relative "../error/invalid_argument_error"
+
+module Dexpace
+ # Reopened for the witness protocol, the way closeable.rb defines Dexpace.close_quietly on the
+ # root namespace: the seam module is phase 2's serde.rb and this file adds the four names the
+ # protocol needs, in the file that mirrors them (design §7.3, §10.14).
+ module Serde
+ # The one name a witness answers: `.dexpace_load(parsed, ctx)`, a class method on a model
+ # class or an instance method on a combinator. Public, and frozen as every Symbol is, so a code
+ # generator emitting witnesses reads the name from here rather than hard-coding a string this
+ # repository could rename; `Native`'s walk and `.witness?` both read it, so `:dexpace_load` is
+ # written exactly once.
+ WITNESS_METHOD = :dexpace_load
+
+ # The one name an encodable value answers: `#dexpace_dump`, a zero-argument instance method
+ # returning the value's codec-native form (a Hash, an Array, a scalar, or OMIT). The encode side
+ # takes no context, because Ruby erases nothing -- every object carries its class -- so there
+ # is no DumpContext and this is the whole protocol.
+ DUMP_METHOD = :dexpace_dump
+
+ class << self
+ # Whether `object` is a witness: anything responding to .dexpace_load(parsed, ctx). A
+ # `respond_to?` test and never a nominal one, for the reason every seam here gives -- a model
+ # class implements the protocol as a class method and a combinator instance as an instance
+ # method, and one predicate must cover both (verified fact 15). The set of witnesses is
+ # therefore OPEN: a caller writes a Set witness or a discriminated-union witness with no
+ # registration, no subclassing and no core change, in two lines:
+ #
+ # class SetOf
+ # def initialize(element) = @element = Dexpace::Serde.witness!(element)
+ # def dexpace_load(parsed, ctx) = ctx.array!(parsed).each_with_index.to_set { |v, i| ... }
+ # end
+ #
+ # A `#call`-shaped object is deliberately NOT a witness. SERDE-5 requires an explicit runtime
+ # type witness and SERDE-8 requires construction to fail fast without one; a `#call` fallback
+ # would make every lambda a witness and both MUSTs unenforceable.
+ #
+ # @param object [Object]
+ # @return [Boolean]
+ def witness?(object) = object.respond_to?(WITNESS_METHOD)
+
+ # SERDE-8's raising half: the object back when it is a witness, and an actionable
+ # InvalidArgumentError naming the missing method and the object's class otherwise. Every
+ # combinator's `.of` and both response handlers' `.build` run their arguments through this,
+ # which is what puts the failure at witness CONSTRUCTION -- earlier than the reference's
+ # binder-resolution failure -- and what makes SERDE-8's "unresolved type variable" state
+ # unreachable: a combinator cannot exist without a concrete element witness.
+ #
+ # @param object [Object]
+ # @return [Object] `object`
+ # @raise [Dexpace::InvalidArgumentError] when `object` does not answer .dexpace_load
+ def witness!(object)
+ return object if witness?(object)
+
+ raise InvalidArgumentError,
+ "a witness must respond to .#{WITNESS_METHOD}(parsed, ctx); #{object.class} does not"
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/http/body.rbs b/gems/dexpace-core/sig/dexpace/http/body.rbs
index db508b9..7073456 100644
--- a/gems/dexpace-core/sig/dexpace/http/body.rbs
+++ b/gems/dexpace-core/sig/dexpace/http/body.rbs
@@ -58,6 +58,7 @@ module Dexpace
?subtype: String) -> Dexpace::MultipartBody
def self.buffer: (Dexpace::IO::Buffer buffer, ?media_type: Dexpace::MediaType?)
-> Dexpace::BufferBody
+ def self.serialized: (untyped value, serde: untyped) -> Dexpace::BytesBody
def self.buffer_bounded: (Dexpace::Body body, ?cap: Integer | Float) -> Dexpace::BufferBody
private def self.clamp_cap: (untyped cap) -> Integer
diff --git a/gems/dexpace-core/sig/dexpace/serde.rbs b/gems/dexpace-core/sig/dexpace/serde.rbs
index 4fbc3a3..0b97b93 100644
--- a/gems/dexpace-core/sig/dexpace/serde.rbs
+++ b/gems/dexpace-core/sig/dexpace/serde.rbs
@@ -1,13 +1,17 @@
module Dexpace
- # The static half of the codec seam: the six methods, typed. `value` and the witness are
- # untyped because the witness protocol is phase 7's (§7.3) and a codec's value space is its own.
+ # The static half of the codec seam: the six methods, typed. `value` is untyped because a
+ # codec's value space is its own. Phase 7a settled the three clauses phase 2 left loose:
+ # #media_type answers a Dexpace::MediaType or a String (Body.serialized coerces the String;
+ # SERDE-2), #load takes a Dexpace::Serde::_Witness (design §7.3's protocol), and #dump_to answers
+ # the byte count written, as the other three encode profiles do. `source` stays untyped because
+ # SERDE-3's subject is "a caller-supplied stream" and phase 2's own test passes a StringIO.
interface _Codec
- def media_type: () -> String
+ def media_type: () -> (Dexpace::MediaType | String)
def dump_string: (untyped value) -> String
def dump_bytes: (untyped value) -> String
- def dump_to: (untyped value, untyped sink) -> untyped
+ def dump_to: (untyped value, untyped sink) -> Integer
def dump_into: (untyped value, String buffer, offset: Integer) -> Integer
- def load: (untyped source, untyped witness) -> untyped
+ def load: (untyped source, Dexpace::Serde::_Witness witness) -> untyped
end
module Serde
diff --git a/gems/dexpace-core/sig/dexpace/serde/decode_context.rbs b/gems/dexpace-core/sig/dexpace/serde/decode_context.rbs
new file mode 100644
index 0000000..b5199aa
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/decode_context.rbs
@@ -0,0 +1,38 @@
+module Dexpace
+ module Serde
+ # The `ctx` a witness receives: the path from the document root, the decode's target type
+ # name, and the one raise site for a shape failure (SERDE-13, SERDE-21, SERDE-22). A frozen
+ # Data in the phase-1 shape; .new and .[] are private and .build is the validating factory.
+ class DecodeContext < Data
+ include Model
+
+ attr_reader path: Array[String | Integer]
+ attr_reader target: String?
+
+ # Two private_constants with no visibility in RBS; declared so Steep can type .root.
+ EMPTY_PATH: Array[String | Integer]
+ ROOT: DecodeContext
+
+ private def self.new: (path: Array[String | Integer], target: String?) -> instance
+ def initialize: (path: Array[String | Integer], target: String?) -> void
+
+ def self.build: (path: Array[String | Integer], target: String?) -> DecodeContext
+ def self.root: (?target: untyped) -> DecodeContext
+
+ def at: (String | Integer segment) -> DecodeContext
+ def pointer: () -> String
+ def object!: (untyped value, ?key: (String | Integer)?) -> Hash[untyped, untyped]
+ def array!: (untyped value, ?key: (String | Integer)?) -> Array[untyped]
+ def string!: (untyped value, ?key: (String | Integer)?) -> String
+ def integer!: (untyped value, ?key: (String | Integer)?) -> Integer
+ def float!: (untyped value, ?key: (String | Integer)?) -> Float
+ def boolean!: (untyped value, ?key: (String | Integer)?) -> bool
+ def present!: (untyped value, String target, ?key: (String | Integer)?) -> untyped
+ def error!: (expected: String, actual: untyped, ?key: (String | Integer)?) -> bot
+
+ private
+
+ def segment?: (untyped segment) -> bool
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs b/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs
new file mode 100644
index 0000000..5b67fae
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs
@@ -0,0 +1,24 @@
+module Dexpace
+ module Serde
+ # SERDE-27's response-decoding handler, a Dexpace::_ResponseHandler supplied into
+ # TypedResponse. A frozen Data; .new and .[] are private and .build is the validating factory.
+ class DecodingHandler < Data
+ include Model
+
+ attr_reader serde: untyped
+ attr_reader witness: untyped
+
+ private def self.new: (serde: untyped, witness: untyped) -> instance
+ def initialize: (serde: untyped, witness: untyped) -> void
+
+ def self.build: (serde: untyped, witness: untyped) -> DecodingHandler
+ def call: (Dexpace::Response response) -> untyped
+
+ private
+
+ def decode: (Dexpace::Response response) -> untyped
+ def missing_body: () -> Dexpace::Serde::DeserializationError
+ def target_name: () -> String
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/instant.rbs b/gems/dexpace-core/sig/dexpace/serde/instant.rbs
new file mode 100644
index 0000000..0178937
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/instant.rbs
@@ -0,0 +1,14 @@
+module Dexpace
+ module Serde
+ # The ISO-8601 instant witness (SERDE-24): the decode half parses, the encode half renders at
+ # microsecond precision (P7-8 states the round-trip domain). ::Time is the one foreign constant
+ # in core's serde signatures and is on NFR-11's stdlib allowlist.
+ module Instant
+ # A private_constant with no visibility in RBS; declared so Steep can type the two raises.
+ EXPECTED: String
+
+ def self?.dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Time
+ def self?.dexpace_dump: (untyped time) -> String
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/list.rbs b/gems/dexpace-core/sig/dexpace/serde/list.rbs
new file mode 100644
index 0000000..9e7eda0
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/list.rbs
@@ -0,0 +1,18 @@
+module Dexpace
+ module Serde
+ # The Array combinator (SERDE-6): a witness for Array[element], built by value. A frozen Data;
+ # .new and .[] are private and .build is the validating factory .of routes through.
+ class List < Data
+ include Model
+
+ attr_reader element: untyped
+
+ private def self.new: (element: untyped) -> instance
+ def initialize: (element: untyped) -> void
+
+ def self.of: (untyped element) -> List
+ def self.build: (element: untyped) -> List
+ def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Array[untyped]
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/map.rbs b/gems/dexpace-core/sig/dexpace/serde/map.rbs
new file mode 100644
index 0000000..44246f5
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/map.rbs
@@ -0,0 +1,19 @@
+module Dexpace
+ module Serde
+ # The Hash combinator (SERDE-6): a witness for Hash[key, value], built by value from a key
+ # witness and a value witness. A frozen Data; .new and .[] are private.
+ class Map < Data
+ include Model
+
+ attr_reader key: untyped
+ attr_reader value: untyped
+
+ private def self.new: (key: untyped, value: untyped) -> instance
+ def initialize: (key: untyped, value: untyped) -> void
+
+ def self.of: (untyped key, untyped value) -> Map
+ def self.build: (key: untyped, value: untyped) -> Map
+ def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Hash[untyped, untyped]
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/native.rbs b/gems/dexpace-core/sig/dexpace/serde/native.rbs
new file mode 100644
index 0000000..870fd1a
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/native.rbs
@@ -0,0 +1,31 @@
+module Dexpace
+ module Serde
+ # A private_constant with no visibility in RBS; declared because the strict `core` Steep
+ # target types OMIT's construction. The privacy lives in lib/dexpace/serde/native.rb.
+ class Omit
+ def to_s: () -> String
+ def inspect: () -> String
+ end
+
+ OMIT: Omit
+
+ # The encode walk (P7-9): SERDE-15's key omission, SERDE-20's three degradations and SERDE-9's
+ # loud failure on an unencodable value, in one place a codec adapter calls.
+ module Native
+ # A private_constant with no visibility in RBS; declared so Steep can type .of's default.
+ NO_ENCODERS: Hash[Module, untyped]
+
+ def self?.of: (untyped value, ?encoders: Hash[Module, untyped]) -> untyped
+
+ private
+
+ def encoders!: (untyped encoders) -> Hash[Module, untyped]
+ def walk: (untyped value, Hash[Module, untyped] encoders) -> untyped
+ def native_scalar?: (untyped value) -> bool
+ def walk_hash: (Hash[untyped, untyped] hash, Hash[Module, untyped] encoders) -> Hash[String, untyped]
+ def walk_array: (Array[untyped] array, Hash[Module, untyped] encoders) -> Array[untyped]
+ def walk_encoded: (untyped value, Hash[Module, untyped] encoders) -> untyped
+ def key!: (untyped key) -> String
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/nullable.rbs b/gems/dexpace-core/sig/dexpace/serde/nullable.rbs
new file mode 100644
index 0000000..9045a88
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/nullable.rbs
@@ -0,0 +1,18 @@
+module Dexpace
+ module Serde
+ # The nullable combinator (SERDE-6): a witness accepting a wire null where its element would
+ # not. A frozen Data; .new and .[] are private.
+ class Nullable < Data
+ include Model
+
+ attr_reader element: untyped
+
+ private def self.new: (element: untyped) -> instance
+ def initialize: (element: untyped) -> void
+
+ def self.of: (untyped element) -> Nullable
+ def self.build: (element: untyped) -> Nullable
+ def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> untyped
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/scalars.rbs b/gems/dexpace-core/sig/dexpace/serde/scalars.rbs
new file mode 100644
index 0000000..3fe19aa
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/scalars.rbs
@@ -0,0 +1,30 @@
+# Dexpace::Serde::Scalars is a private_constant and not public API: this declaration exists
+# because the strict `core` Steep target checks every file under lib/ and needs the module, its
+# table and its witness class declared to type the combinators' constructors. RBS has no
+# visibility for a constant, so the privacy lives in lib/dexpace/serde/scalars.rb alone.
+module Dexpace
+ module Serde
+ module Scalars
+ class Scalar
+ @name: String
+ @check: ^(untyped, Dexpace::Serde::DecodeContext) -> untyped
+
+ def initialize: (String name, ^(untyped, Dexpace::Serde::DecodeContext) -> untyped check) -> void
+ def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> untyped
+ def to_s: () -> String
+ def inspect: () -> String
+ end
+
+ STRING: Scalar
+ INTEGER: Scalar
+ FLOAT: Scalar
+ BOOLEAN: Scalar
+ TABLE: Hash[Module, Scalar]
+
+ def self?.resolve: (untyped witness) -> untyped
+ end
+
+ # The boolean scalar witness: a named witness where the other three scalars are class objects.
+ BOOLEAN: _Witness
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs b/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs
new file mode 100644
index 0000000..45b41b3
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs
@@ -0,0 +1,32 @@
+module Dexpace
+ module Serde
+ # SERDE-28's status-aware response handler, a Dexpace::_ResponseHandler supplied into
+ # TypedResponse: 2xx decodes, 4xx/5xx raises the mapped error over the buffered body, anything
+ # else closes and raises leading with the code. A frozen Data; .new and .[] are private.
+ class StatusAwareHandler < Data
+ include Model
+
+ # A private_constant with no visibility in RBS; declared so Steep can type .build's default.
+ DEFAULT_FACTORY: ^(Dexpace::Response) -> Exception
+
+ @decoding: DecodingHandler
+
+ attr_reader serde: untyped
+ attr_reader witness: untyped
+ attr_reader factory: untyped
+
+ private def self.new: (serde: untyped, witness: untyped, factory: untyped) -> instance
+ def initialize: (serde: untyped, witness: untyped, factory: untyped) -> void
+
+ def self.build: (serde: untyped, witness: untyped, ?factory: untyped) -> StatusAwareHandler
+ def call: (Dexpace::Response response) -> untyped
+
+ private
+
+ def raise_mapped: (Dexpace::Response response) -> bot
+ def raise_unhandled: (Dexpace::Response response) -> bot
+ def unhandled_message: (Dexpace::Response response) -> String
+ def target_name: () -> String
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/tristate.rbs b/gems/dexpace-core/sig/dexpace/serde/tristate.rbs
new file mode 100644
index 0000000..0fb11fa
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/tristate.rbs
@@ -0,0 +1,75 @@
+module Dexpace
+ module Serde
+ # The three-state PATCH type (SERDE-14–SERDE-20, SERDE-30): a module included by ABSENT, NULL
+ # and Present, so one type test covers the three values.
+ module Tristate
+ def absent?: () -> bool
+ def null?: () -> bool
+ def present?: () -> bool
+ def value_or_nil: () -> untyped
+ def fold: (on_absent: ^() -> untyped, on_null: ^() -> untyped, on_present: ^(untyped) -> untyped) -> untyped
+
+ # Two private_constants with no visibility in RBS; declared because the strict `core` Steep
+ # target types the two sentinels' construction. The privacy lives in
+ # lib/dexpace/serde/tristate.rb.
+ class Absent
+ include Tristate
+
+ def absent?: () -> bool
+ def dexpace_dump: () -> Omit
+ def to_s: () -> String
+ def inspect: () -> String
+ end
+
+ class Null
+ include Tristate
+
+ def null?: () -> bool
+ def dexpace_dump: () -> nil
+ def to_s: () -> String
+ def inspect: () -> String
+ end
+
+ ABSENT: Tristate
+ NULL: Tristate
+
+ # A Present value, bounded to non-null (SERDE-14). A frozen Data; .new and .[] are private
+ # and .build is the validating factory.
+ class Present < Data
+ include Model
+ include Tristate
+
+ attr_reader value: untyped
+
+ private def self.new: (value: untyped) -> instance
+ def initialize: (value: untyped) -> void
+
+ def self.build: (value: untyped) -> Present
+ def present?: () -> bool
+ def value_or_nil: () -> untyped
+ def dexpace_dump: () -> untyped
+ end
+
+ # A private_constant with no visibility in RBS; declared because the strict `core` Steep
+ # target types .of. The privacy lives in lib/dexpace/serde/tristate.rb.
+ class Combinator < Data
+ include Model
+
+ attr_reader element: untyped
+
+ private def self.new: (element: untyped) -> instance
+ def initialize: (element: untyped) -> void
+
+ def self.build: (element: untyped) -> Combinator
+ def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Tristate
+ def dexpace_load_field: (untyped hash, String key, Dexpace::Serde::DecodeContext ctx) -> Tristate
+ end
+
+ def self.absent: () -> Tristate
+ def self.null: () -> Tristate
+ def self.present: (untyped value) -> Present
+ def self.from_nullable: (untyped value) -> Tristate
+ def self.of: (untyped element) -> Combinator
+ end
+ end
+end
diff --git a/gems/dexpace-core/sig/dexpace/serde/witness.rbs b/gems/dexpace-core/sig/dexpace/serde/witness.rbs
new file mode 100644
index 0000000..b1013e4
--- /dev/null
+++ b/gems/dexpace-core/sig/dexpace/serde/witness.rbs
@@ -0,0 +1,18 @@
+module Dexpace
+ module Serde
+ # The witness protocol (design §7.3, §10.14): any object answering .dexpace_load(parsed, ctx).
+ # An interface and not a class, because a structural duck type is what it is -- a caller's
+ # own model class type-checks against List.of without inheriting anything. `parsed` and the
+ # return are untyped because a witness's target type is the caller's; RBS and Steep are a
+ # gate rather than a guarantee here.
+ interface _Witness
+ def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> untyped
+ end
+
+ WITNESS_METHOD: Symbol
+ DUMP_METHOD: Symbol
+
+ def self.witness?: (untyped object) -> bool
+ def self.witness!: (untyped object) -> untyped
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/http/body_serialized_test.rb b/gems/dexpace-core/test/dexpace/http/body_serialized_test.rb
new file mode 100644
index 0000000..6349d25
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/http/body_serialized_test.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require_relative "../../support/fake_codec"
+require "dexpace"
+require "stringio"
+
+# SERDE-2, whose own conformance clause is: "build a body via create(value, serde) with no explicit
+# media type; assert the Content-Type equals the serde's declared media type." A ninth factory
+# beside phase 3b's eight; adding one WIDENS, which NFR-4's "disappears or narrows" lock permits. An
+# extra suite beside 3b's body_test.rb, the file's mirror, because it is phase 7a's one addition.
+class DexpaceBodySerializedTest < DexpaceTestCase
+ test "SERDE-2: the body's media type is the serde's declared one" do
+ body = Dexpace::Body.serialized(:payload, serde: FakeCodec.new)
+
+ assert_equal(Dexpace::MediaType.parse("application/vnd.dexpace.fake"), body.media_type)
+ end
+
+ test "SERDE-2: there is no format-agnostic default to fall back to" do
+ forgetful = Class.new(FakeCodec) { def media_type = nil }.new
+ blank = Class.new(FakeCodec) { def media_type = "" }.new
+
+ error = assert_raises(Dexpace::InvalidArgumentError) do
+ Dexpace::Body.serialized(:payload, serde: forgetful)
+ end
+
+ assert_match(/media type/, error.message)
+ refute_match(%r{application/octet-stream}, error.message)
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Body.serialized(:payload, serde: blank) }
+ end
+
+ test "a MediaType-returning codec passes through and a String is coerced" do
+ stringy = FakeCodec.new
+ typed = Class.new(FakeCodec) do
+ def media_type = Dexpace::MediaType.parse("application/json")
+ end.new
+
+ assert_instance_of(Dexpace::MediaType, Dexpace::Body.serialized(:v, serde: stringy).media_type)
+ assert_equal(Dexpace::MediaType.parse("application/json"),
+ Dexpace::Body.serialized(:v, serde: typed).media_type,)
+ assert_same(typed.media_type.class, Dexpace::Body.serialized(:v, serde: typed).media_type.class)
+ end
+
+ test "a media type the header grammar cannot carry is refused at the parse, naming the value" do
+ hostile = Class.new(FakeCodec) { def media_type = "application/json\r\nX: y" }.new
+
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Body.serialized(:v, serde: hostile) }
+ end
+
+ test "the bytes are the serde's dump_bytes and the body is replayable with an exact length" do
+ codec = FakeCodec.new
+ body = Dexpace::Body.serialized(:payload, serde: codec)
+ sink = StringIO.new(+"".b)
+
+ assert_instance_of(Dexpace::BytesBody, body)
+ assert_predicate(body, :replayable?)
+ assert_equal(codec.dump_bytes(:payload).bytesize, body.content_length)
+ body.write_to(sink)
+ body.write_to(sink) # replayable means byte-for-byte identical, twice
+
+ assert_equal(codec.dump_bytes(:payload) * 2, sink.string)
+ end
+
+ test "non-ASCII content reaches the body as the codec's BINARY bytes" do
+ body = Dexpace::Body.serialized("héllo", serde: FakeCodec.new)
+ sink = StringIO.new(+"".b)
+ body.write_to(sink)
+
+ assert_equal("héllo".b, sink.string)
+ assert_equal(6, body.content_length, "a byte count, never a character count")
+ end
+
+ test "a missing serde fails with SEAM-29's message form, and a non-codec by name" do
+ error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Body.serialized(:v, serde: nil) }
+
+ assert_equal("serde is required", error.message)
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Body.serialized(:v, serde: Object.new) }
+ end
+
+ test "a serialization failure propagates as the seam's write-side error" do
+ exploding = Class.new(FakeCodec) do
+ def dump_bytes(_value) = raise Dexpace::Serde::SerializationError, "no"
+ end.new
+
+ assert_raises(Dexpace::Serde::SerializationError) do
+ Dexpace::Body.serialized(:v, serde: exploding)
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/seam_surface_test.rb b/gems/dexpace-core/test/dexpace/seam_surface_test.rb
index 521ea0f..d798281 100644
--- a/gems/dexpace-core/test/dexpace/seam_surface_test.rb
+++ b/gems/dexpace-core/test/dexpace/seam_surface_test.rb
@@ -13,16 +13,36 @@ class DexpaceSeamSurfaceTest < DexpaceTestCase
# Every concrete implementation SEAM-2 could tempt an error message into naming.
CONCRETE = %r{net_http|async_http|net/http|async-http|\bjson\b|\boj\b|httpx|excon|typhoeus}i
+ # "Starts empty on a bare require" is a property of a process that required `dexpace` ALONE, and
+ # `rake test:gems` is not one: it runs every gem's suite in one process, and an adapter registers
+ # itself against its seam the moment its entry file loads (design §3.6; phase 7a's
+ # dexpace-serde-json is the first). So the two properties below are asserted in a CHILD process --
+ # the shape instrumentation/independence_test.rb uses -- that requires core and nothing else, and
+ # prints one line per seam. Converted by phase 7a as pins its registration invalidated.
+ GEM_ROOT = File.expand_path("../..", __dir__)
+ BARE_REQUIRE = <<~RUBY
+ require "dexpace"
+ [Dexpace::Transport, Dexpace::AsyncTransport, Dexpace::Serde].each do |seam|
+ keys = seam.registered_keys
+ message =
+ begin
+ seam.resolve
+ "RESOLVED"
+ rescue Dexpace::SeamError => error
+ error.message
+ end
+ puts [seam.name, keys.inspect, message].join("\t")
+ end
+ RUBY
+
test "every seam registry starts empty on a bare require" do
- SEAMS.each { |seam| assert_empty(seam.registered_keys, "#{seam} is not empty") }
+ bare_require_report.each { |name, keys, _| assert_equal("[]", keys, "#{name} is not empty") }
end
test "no seam's zero-candidate error names a concrete gem" do
- SEAMS.each do |seam|
- error = assert_raises(Dexpace::SeamError) { seam.resolve }
-
- refute_match(CONCRETE, error.message,
- "SEAM-2: #{seam} names a concrete implementation in its error path",)
+ bare_require_report.each do |name, _, message|
+ refute_equal("RESOLVED", message, "#{name} resolved something on a bare require")
+ refute_match(CONCRETE, message, "SEAM-2: #{name} names a concrete implementation")
end
end
@@ -87,4 +107,17 @@ class DexpaceSeamSurfaceTest < DexpaceTestCase
registries.each { |registry| assert_instance_of(Dexpace::Registry, registry) }
assert_equal(["transport", "async transport", "codec"], registries.map(&:seam))
end
+
+ private
+
+ # One row per seam from the child: [name, registered keys' inspect, the resolve outcome].
+ def bare_require_report
+ command = [::RbConfig.ruby, "-w", "-Ilib", "-e", BARE_REQUIRE]
+ out = IO.popen(command, err: %i[child out], chdir: GEM_ROOT, &:read)
+ rows = out.lines.map { |line| line.chomp.split("\t", 3) }
+
+ assert_equal(SEAMS.map(&:name), rows.map(&:first),
+ "the child did not report every seam:\n#{out}",)
+ rows
+ end
end
diff --git a/gems/dexpace-core/test/dexpace/serde/decode_context_test.rb b/gems/dexpace-core/test/dexpace/serde/decode_context_test.rb
new file mode 100644
index 0000000..6c99e1e
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/decode_context_test.rb
@@ -0,0 +1,233 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-13, SERDE-21, SERDE-22. The strictness burden moves into the witness (serde/b5e5efc8) --
+# which makes it a per-field discipline unless something makes it a shared helper. This is that
+# helper, and it is Model.required!'s discipline applied to a second family of failures: ONE raise
+# site, ONE message form, so "naming the target type" is a property of one method rather than of
+# every witness anyone writes. Split under Metrics/ClassLength: the value and its path, then the
+# nine refusals and two permissions, then the one raise site.
+class DexpaceSerdeDecodeContextTest < DexpaceTestCase
+ DC = Dexpace::Serde::DecodeContext
+
+ # The root context every nested case starts from.
+ module Fixtures
+ def ctx = DC.root
+ end
+
+ # The value: construction, the path, the target name.
+ class ValueTest < DexpaceTestCase
+ include Fixtures
+
+ test "the root context has an empty frozen path and is itself frozen" do
+ assert_empty(ctx.path)
+ assert_predicate(ctx.path, :frozen?)
+ assert_predicate(ctx, :frozen?)
+ assert_nil(ctx.target)
+ assert_same(ctx, DC.root, "the no-target root is one shared instance")
+ end
+
+ test ".root takes a class, a module, a String or an instance and stores a NAME" do
+ assert_equal("String", DC.root(target: ::String).target)
+ assert_predicate(DC.root(target: ::String).target, :frozen?)
+ assert_equal("Comparable", DC.root(target: ::Comparable).target)
+ assert_equal("Pet", DC.root(target: "Pet").target)
+ combinator = Object.new
+ def combinator.dexpace_load(parsed, _ctx) = parsed
+
+ assert_equal("Object", DC.root(target: combinator).target)
+ assert_nil(DC.root(target: Class.new).target, "an anonymous class has no name to carry")
+ end
+
+ test "#at appends one segment and returns a new context, leaving the receiver untouched" do
+ child = ctx.at("pets").at(3).at("name")
+
+ assert_equal(["pets", 3, "name"], child.path)
+ assert_predicate(child.path, :frozen?)
+ assert_empty(ctx.path)
+ end
+
+ test "#at carries the target and refuses a segment that is neither a String nor an Integer" do
+ assert_equal("Pet", DC.root(target: "Pet").at("tags").target)
+ error = assert_raises(Dexpace::InvalidArgumentError) { ctx.at(:name) }
+
+ assert_match(/segment/, error.message)
+ end
+
+ # The construction pattern holds: .new and .[] are private, .build validates, #with
+ # re-validates.
+ test "follows the construction pattern: private constructors, a validating .build, #with" do
+ refute_respond_to(DC, :new)
+ refute_respond_to(DC, :[])
+ assert_equal(["a"], DC.build(path: ["a"], target: nil).path)
+ assert_equal("Pet", DC.build(path: [], target: nil).with(target: "Pet").target)
+ assert_raises(Dexpace::InvalidArgumentError) { DC.build(path: nil, target: nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { DC.build(path: [:a], target: nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { DC.build(path: [], target: 5) }
+ end
+
+ test "#path is the model's own frozen copy, never the caller's array" do
+ segments = ["a"]
+ context = DC.build(path: segments, target: nil)
+ segments << "b"
+
+ assert_equal(["a"], context.path)
+ end
+
+ test "the path renders as an RFC 6901 pointer with ~0 and ~1 escaping" do
+ assert_equal("/a~1b/c~0d", ctx.at("a/b").at("c~d").pointer)
+ assert_equal("/pets/0/name", ctx.at("pets").at(0).at("name").pointer)
+ assert_equal("", ctx.pointer, "RFC 6901: the whole document is the empty pointer")
+ end
+ end
+
+ # SERDE-21: the nine named cross-shape coercions, one fixture each -- a loop would hide a dropped
+ # case, and the requirement enumerates them individually. SERDE-22: the two permissions.
+ class CoercionTest < DexpaceTestCase
+ include Fixtures
+
+ test "SERDE-21: string to integer is rejected" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.integer!("5") }
+ end
+
+ test "SERDE-21: string to float is rejected" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.float!("1.5") }
+ end
+
+ test "SERDE-21: string to boolean is rejected" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.boolean!("true") }
+ end
+
+ test "SERDE-21: empty string to integer, float and boolean are all rejected" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.integer!("") }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.float!("") }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.boolean!("") }
+ end
+
+ test "SERDE-21: float to integer is rejected (lossy narrowing)" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.integer!(1.5) }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.integer!(1.0) }
+ end
+
+ test "SERDE-21: boolean to integer and integer to boolean are both rejected" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.integer!(true) }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.boolean!(1) }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.boolean!(0) }
+ end
+
+ test "SERDE-21: boolean to float is rejected" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.float!(true) }
+ end
+
+ test "SERDE-21: a non-string scalar to string is rejected" do
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.string!(5) }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.string!(true) }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.string!(1.5) }
+ end
+
+ # An implementation that rejects these has broken a MUST while looking stricter and therefore
+ # more correct.
+ test "SERDE-22: an integer widens into a float target" do
+ widened = ctx.float!(1)
+
+ assert_in_delta(1.0, widened)
+ assert_instance_of(::Float, widened)
+ assert_in_delta(1.5, ctx.float!(1.5))
+ end
+
+ test "SERDE-22: an empty string binds to a textual target" do
+ assert_equal("", ctx.string!(""))
+ end
+
+ test "SERDE-22: every well-typed value binds to its matching target unchanged" do
+ assert_equal(5, ctx.integer!(5))
+ assert_equal("é", ctx.string!("é"))
+ assert(ctx.boolean!(true))
+ refute(ctx.boolean!(false))
+ end
+
+ test "#object! and #array! reject the wrong container and accept the right one" do
+ assert_equal({ "a" => 1 }, ctx.object!({ "a" => 1 }))
+ assert_equal([1], ctx.array!([1]))
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.object!([]) }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.array!({}) }
+ assert_raises(Dexpace::Serde::DeserializationError) { ctx.array!(nil) }
+ end
+ end
+
+ # SERDE-13: the one raise site, and its message form.
+ class RaiseSiteTest < DexpaceTestCase
+ include Fixtures
+
+ # SERDE-13's conformance clause decodes "the literal null into a non-null DTO" and asserts the
+ # message names THE TARGET TYPE. A witness reached with nil calls ctx.object!(nil), which knows
+ # only the shape it wanted -- so without a target on the root frame the message names Hash where
+ # the requirement asks for Pet. The target is carried by #at but only RENDERED at the root,
+ # because a nested frame's target IS its expected shape.
+ test "SERDE-13: the root frame names the target type, and a nested frame does not" do
+ root = DC.root(target: "Pet")
+
+ at_root = assert_raises(Dexpace::Serde::DeserializationError) { root.object!(nil) }
+ nested = assert_raises(Dexpace::Serde::DeserializationError) { root.at("tags").string!(nil) }
+
+ assert_equal("expected Pet (Hash) at /, got NilClass", at_root.message)
+ assert_equal("expected String at /tags, got NilClass", nested.message)
+ end
+
+ test "SERDE-13: a wire null into a non-null target names the target type and the path" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ ctx.at("pets").at(0).string!(nil, key: "name")
+ end
+
+ assert_equal("expected String at /pets/0/name, got NilClass", error.message)
+ end
+
+ test "key: appends one segment for the message only and leaves the receiver's path alone" do
+ context = ctx.at("pet")
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ context.integer!("x", key: "id")
+ end
+
+ assert_match(%r{ at /pet/id,}, error.message)
+ assert_equal(["pet"], context.path)
+ end
+
+ test "the message renders the escaped pointer" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ ctx.at("a/b").at("c~d").string!(1)
+ end
+
+ assert_equal("expected String at /a~1b/c~0d, got Integer", error.message)
+ end
+
+ test "#present! rejects nil and names the caller's target; false is a present value" do
+ assert_equal(5, ctx.present!(5, "Pet"))
+ assert_same(false, ctx.present!(false, "Flag"))
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) { ctx.present!(nil, "Pet") }
+
+ assert_equal("expected Pet at /, got NilClass", error.message)
+ end
+
+ test "#present! at a root frame whose target is the same name does not name it twice" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ DC.root(target: "Pet").present!(nil, "Pet")
+ end
+
+ assert_equal("expected Pet at /, got NilClass", error.message)
+ end
+
+ test "#error! is the one raise site and its error is the seam's decode subtype" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ ctx.error!(expected: "Thing", actual: 5, key: "k")
+ end
+
+ assert_kind_of(Dexpace::Serde::Error, error)
+ assert_kind_of(Dexpace::Error, error)
+ assert_equal("expected Thing at /k, got Integer", error.message)
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/decoding_handler_test.rb b/gems/dexpace-core/test/dexpace/serde/decoding_handler_test.rb
new file mode 100644
index 0000000..94b2078
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/decoding_handler_test.rb
@@ -0,0 +1,254 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require_relative "../../support/fake_codec"
+require_relative "../../support/fake_response_body"
+require_relative "../../support/recovery_fixtures"
+require "dexpace"
+
+# SERDE-27, whose conformance clause is a five-case matrix and gets five tests: "handle a valid body
+# -> typed value plus one close; a bodyless response -> serde exception naming the target; malformed
+# content -> serde exception with a non-null cause; a mid-stream I/O error -> propagates unwrapped;
+# the response closes in every case."
+#
+# FakeCodec, never Dexpace::Serde::JSON -- SEAM-2, and no_concrete_codec_test.rb enforces it. The
+# response is a REAL Dexpace::Response (TypedResponse type-checks it) over 3b's FakeResponseBody,
+# whose #closes counts RAW close calls -- a Closeable-latched body could never show a second close.
+# Split under Metrics/ClassLength: the matrix, then the construction and the TypedResponse wiring.
+class DexpaceSerdeDecodingHandlerTest < DexpaceTestCase
+ S = Dexpace::Serde
+
+ # A named witness answering BOTH protocol methods: .dexpace_load for Dexpace::Serde.witness!,
+ # which DecodingHandler.build runs, and .call for phase 2's FakeCodec#load, which predates the
+ # protocol. A bare lambda answers only the second and fails at .build -- and widening witness! to
+ # accept #call would break SERDE-5 and SERDE-8. FakeCodec hands #call the drained BINARY bytes, so
+ # the witness retags before it folds.
+ class UpcaseWitness
+ def self.dexpace_load(parsed, _ctx) = parsed.dup.force_encoding(::Encoding::UTF_8).upcase
+ def self.call(text) = dexpace_load(text, nil)
+ end
+
+ # The target whose NAME the bodyless error must carry.
+ class PetWitness
+ def self.dexpace_load(parsed, ctx) = ctx.object!(parsed)
+ def self.call(text) = dexpace_load(text, S::DecodeContext.root(target: self))
+ end
+
+ # The responses and handlers the nested cases share.
+ module Fixtures
+ include RecoveryFixtures
+
+ def body_of(text)
+ FakeResponseBody.new(Dexpace::IO::BufferedSource.of_bytes(text.b),
+ content_length: text.bytesize,)
+ end
+
+ def response_with(text, code: 200) = build_response(code, body: body_of(text))
+
+ def handler(serde: FakeCodec.new, witness: UpcaseWitness)
+ S::DecodingHandler.build(serde: serde, witness: witness)
+ end
+ end
+
+ # SERDE-27's five-case conformance matrix, plus the two-subjects clause.
+ class MatrixTest < DexpaceTestCase
+ include Fixtures
+
+ test "SERDE-27: a valid body decodes to the typed value and closes the response exactly once" do
+ response = response_with("héllo")
+
+ assert_equal("HÉLLO", handler.call(response))
+ assert_equal(1, response.body.closes)
+ end
+
+ # "MUST surface a missing body (e.g. 204) as a serde exception NAMING THE TARGET TYPE".
+ test "SERDE-27: a bodyless response raises naming the target, and still closes" do
+ response = build_response(204, body: nil)
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ handler(witness: PetWitness).call(response)
+ end
+
+ assert_match(/PetWitness/, error.message)
+ assert_match(/no body/, error.message)
+ end
+
+ # A zero-length body is treated the same way, because a caller cannot distinguish them and the
+ # codec's own end-of-input error names nothing useful. Detected with BufferedSource#eof?, a
+ # non-consuming probe, never with #content_length (-1 for every unknown-length body) and never
+ # by matching a parser message (which differs across json versions). Both halves of the message
+ # are asserted: with the screen gone, FakeCodec hands PetWitness the drained "" and the
+ # witness's OWN shape failure ("expected PetWitness (Hash) at /, got String") names the target
+ # too, so /PetWitness/ alone would pass either way (review round 1, R1-1).
+ test "SERDE-27: an empty body raises the same target-naming error, at any declared length" do
+ empty = FakeResponseBody.new(Dexpace::IO::BufferedSource.of_bytes("".b))
+ response = build_response(200, body: empty)
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ handler(witness: PetWitness).call(response)
+ end
+
+ assert_match(/PetWitness/, error.message)
+ assert_match(/no body/, error.message)
+ assert_equal(1, response.body.closes)
+ end
+
+ # The same clause for a witness that has no name to carry: DecodeContext.root gives an
+ # anonymous class no target (P7-70), and the message says so in words rather than
+ # interpolating nothing (review round 1, R1-4).
+ test "SERDE-27: an anonymous witness is named as such, never as an empty name" do
+ anonymous = Class.new { def self.dexpace_load(parsed, ctx) = ctx.object!(parsed) }
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ handler(witness: anonymous).call(build_response(204, body: nil))
+ end
+
+ assert_match(/no body to decode into an anonymous witness:/, error.message)
+ refute_match(/# ISO-8601 string, deserialize ->
+ # equality with the original.
+ test "SERDE-24: whole seconds round-trip exactly" do
+ t = ::Time.utc(2026, 9, 10, 12, 0, 0)
+
+ assert_equal(t, S::Instant.dexpace_load(S::Instant.dexpace_dump(t), ctx))
+ end
+
+ test "SERDE-24: exact microseconds round-trip exactly -- the stated domain" do
+ t = ::Time.at(1_757_505_600, 123_456, :usec).utc
+
+ assert_equal(t, S::Instant.dexpace_load(S::Instant.dexpace_dump(t), ctx))
+ end
+
+ # A seeded property test over the domain (styleguide 11.7): integer-microsecond Times round-trip.
+ test "SERDE-24: every integer-microsecond Time in a seeded sample round-trips" do
+ sample(count: 64) do |rng|
+ t = ::Time.at(rng.rand(0..4_102_444_800), rng.rand(0..999_999), :usec).utc
+
+ assert_equal(t, S::Instant.dexpace_load(S::Instant.dexpace_dump(t), ctx))
+ end
+ end
+
+ test "SERDE-24: a UTC offset survives the round trip" do
+ t = ::Time.new(2026, 9, 10, 12, 0, 0, "+02:00")
+ back = S::Instant.dexpace_load(S::Instant.dexpace_dump(t), ctx)
+
+ assert_equal(t, back)
+ assert_equal(7200, back.utc_offset)
+ end
+
+ # P7-8, executable rather than prose: OUTSIDE the domain the encoding truncates and the round trip
+ # is lossy. Asserting it is what keeps the YARD caveat honest.
+ test "P7-8: a Float-second Time truncates and does NOT round-trip" do
+ t = ::Time.new(2026, 9, 10, 12, 0, 0.123456, "+02:00")
+
+ assert_equal("2026-09-10T12:00:00.123455+02:00", S::Instant.dexpace_dump(t))
+ refute_equal(t, S::Instant.dexpace_load(S::Instant.dexpace_dump(t), ctx))
+ end
+
+ test "a non-string, a malformed string and a lax form each raise naming Time" do
+ [5, "not a time", "", "2026-09-10", "2026-09-10 12:00:00", nil].each do |bad|
+ error = assert_raises(Dexpace::Serde::DeserializationError) { S::Instant.dexpace_load(bad, ctx) }
+
+ assert_match(/Time/, error.message)
+ assert_match(%r{ at /}, error.message)
+ end
+ end
+
+ test "the failure names the field's path through the context" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ S::Instant.dexpace_load("nope", ctx.at("created_at"))
+ end
+
+ assert_equal("expected Time (ISO-8601) at /created_at, got String", error.message)
+ end
+
+ test "dexpace_dump refuses anything that is not a Time, naming the class" do
+ error = assert_raises(Dexpace::Serde::SerializationError) { S::Instant.dexpace_dump("2026") }
+
+ assert_match(/String/, error.message)
+ end
+
+ test "Instant is a witness and nests in a combinator" do
+ assert(S.witness?(S::Instant))
+ assert_equal([::Time.utc(2026, 9, 10)],
+ S::List.of(S::Instant).dexpace_load(["2026-09-10T00:00:00.000000Z"], ctx),)
+ assert_predicate(S::Tristate.of(S::Instant).dexpace_load(nil, ctx), :null?)
+ end
+
+ test "::Time itself is not in the scalar table: the wiring is the adapter's, not core's" do
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(::Time) }
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/list_test.rb b/gems/dexpace-core/test/dexpace/serde/list_test.rb
new file mode 100644
index 0000000..a785c15
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/list_test.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-6, SERDE-8, SERDE-23. Parametric targets are covered by combinators that are themselves
+# witnesses, each built BY VALUE from a concrete element witness -- so a parametric target is stated
+# once, as data, with no reflective reconstruction anywhere.
+class DexpaceSerdeListTest < DexpaceTestCase
+ S = Dexpace::Serde
+
+ # A model class that is a witness: the smallest DTO with one typed field.
+ class Pet
+ attr_reader :name
+
+ def self.dexpace_load(parsed, ctx)
+ h = ctx.object!(parsed)
+ new(ctx.string!(h["name"], key: "name"))
+ end
+
+ def initialize(name) = @name = name
+ end
+
+ def ctx = S::DecodeContext.root
+
+ # SERDE-5's own conformance clause: "decode a JSON object into a concrete DTO via the type-witness
+ # path; assert the result is the real DTO type and field access returns typed values".
+ test "SERDE-6: a list of a DTO decodes to real DTOs with typed field access" do
+ pets = S::List.of(Pet).dexpace_load([{ "name" => "Ré" }, { "name" => "b" }], ctx)
+
+ assert_equal([Pet, Pet], pets.map(&:class))
+ assert_equal("Ré", pets.first.name)
+ end
+
+ test "the ergonomic scalar spellings design §7.3 uses work verbatim" do
+ assert_equal(%w[a b], S::List.of(String).dexpace_load(%w[a b], ctx))
+ assert_equal([1, 2], S::List.of(Integer).dexpace_load([1, 2], ctx))
+ assert_equal([1.0], S::List.of(Float).dexpace_load([1], ctx)) # SERDE-22 widening
+ assert_equal([true], S::List.of(S::BOOLEAN).dexpace_load([true], ctx))
+ end
+
+ # SERDE-8: reject construction with no type argument or an unresolved one, failing FAST -- at
+ # witness construction, not deep inside a parse. The "unresolved type variable" state is
+ # unreachable by construction (serde/ffc92673) and is stated in the YARD, not emulated.
+ test "SERDE-8: a list cannot be built from a non-witness" do
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(Object.new) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(::Symbol) }
+ end
+
+ test "a combinator is itself a witness, so combinators nest" do
+ nested = S::List.of(S::List.of(String))
+
+ assert(S.witness?(nested))
+ assert_equal([%w[a]], nested.dexpace_load([%w[a]], ctx))
+ end
+
+ test "a combinator is a frozen value with structural equality, built through .build" do
+ assert_equal(S::List.of(Pet), S::List.of(Pet))
+ assert_equal(S::List.of(String), S::List.build(element: String))
+ assert_predicate(S::List.of(Pet), :frozen?)
+ refute_respond_to(S::List, :new)
+ refute_respond_to(S::List, :[])
+ assert_equal(S::List.of(Integer), S::List.of(String).with(element: Integer))
+ end
+
+ test "the element error names the element's own path, not the container's" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ S::List.of(Pet).dexpace_load([{ "name" => 1 }], ctx)
+ end
+
+ assert_equal("expected String at /0/name, got Integer", error.message)
+ end
+
+ test "a non-array names Array at the list's own path" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ S::List.of(String).dexpace_load({ "a" => 1 }, ctx.at("tags"))
+ end
+
+ assert_equal("expected Array at /tags, got Hash", error.message)
+ end
+
+ test "the decoded list is a fresh Array and never the parsed one" do
+ parsed = %w[a b]
+
+ refute_same(parsed, S::List.of(String).dexpace_load(parsed, ctx))
+ end
+
+ test "the root context names the combinator's class when it is the decode's target" do
+ assert_equal("Dexpace::Serde::List", S::DecodeContext.root(target: S::List.of(Pet)).target)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/map_test.rb b/gems/dexpace-core/test/dexpace/serde/map_test.rb
new file mode 100644
index 0000000..4260ee3
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/map_test.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-6, SERDE-8: the Hash-shaped combinator. The key witness is REQUIRED rather than assumed to
+# be String, so a codec whose keys are not strings inherits the combinator unchanged.
+class DexpaceSerdeMapTest < DexpaceTestCase
+ S = Dexpace::Serde
+
+ # A model class that is a witness.
+ class Pet
+ attr_reader :name
+
+ def self.dexpace_load(parsed, ctx)
+ h = ctx.object!(parsed)
+ new(ctx.string!(h["name"], key: "name"))
+ end
+
+ def initialize(name) = @name = name
+ end
+
+ def ctx = S::DecodeContext.root
+
+ test "SERDE-6: a map keyed by String and valued by a DTO" do
+ map = S::Map.of(String, Pet).dexpace_load({ "a" => { "name" => "x" } }, ctx)
+
+ assert_instance_of(Pet, map["a"])
+ assert_equal("x", map["a"].name)
+ end
+
+ test "the ergonomic scalar spellings work for both positions" do
+ assert_equal({ "a" => 1.0 }, S::Map.of(String, Float).dexpace_load({ "a" => 1 }, ctx))
+ assert_equal({ "a" => true }, S::Map.of(String, S::BOOLEAN).dexpace_load({ "a" => true }, ctx))
+ end
+
+ test "SERDE-8: a map cannot be built from a non-witness in either position" do
+ assert_raises(Dexpace::InvalidArgumentError) { S::Map.of(String, nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::Map.of(nil, String) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::Map.of(Object.new, Pet) }
+ end
+
+ test "keys go through the key witness, so a key of the wrong shape is a shape failure too" do
+ integer_keyed = S::Map.of(Integer, String)
+
+ assert_equal({ 1 => "a" }, integer_keyed.dexpace_load({ 1 => "a" }, ctx))
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ integer_keyed.dexpace_load({ "1" => "a" }, ctx)
+ end
+
+ assert_equal("expected Integer at /1, got String", error.message)
+ end
+
+ test "a value error names the entry's own path" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ S::Map.of(String, Pet).dexpace_load({ "a" => { "name" => 1 } }, ctx.at("pets"))
+ end
+
+ assert_equal("expected String at /pets/a/name, got Integer", error.message)
+ end
+
+ test "a non-object names Hash at the map's own path" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ S::Map.of(String, String).dexpace_load([], ctx)
+ end
+
+ assert_equal("expected Hash at /, got Array", error.message)
+ end
+
+ test "a map is a frozen value with structural equality, built through .build like a model" do
+ assert_equal(S::Map.of(String, Pet), S::Map.of(String, Pet))
+ assert_equal(S::Map.of(String, Pet), S::Map.build(key: String, value: Pet))
+ assert_predicate(S::Map.of(String, Pet), :frozen?)
+ refute_respond_to(S::Map, :new)
+ refute_respond_to(S::Map, :[])
+ assert_equal(S::Map.of(String, Integer), S::Map.of(String, Pet).with(value: Integer))
+ end
+
+ test "the decoded map is a fresh Hash and never the parsed one" do
+ parsed = { "a" => "b" }
+
+ refute_same(parsed, S::Map.of(String, String).dexpace_load(parsed, ctx))
+ end
+
+ test "a map nests inside a list and a list inside a map" do
+ assert_equal([{ "a" => [1] }],
+ S::List.of(S::Map.of(String, S::List.of(Integer)))
+ .dexpace_load([{ "a" => [1] }], ctx),)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/native_test.rb b/gems/dexpace-core/test/dexpace/serde/native_test.rb
new file mode 100644
index 0000000..636af24
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/native_test.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require "time"
+
+# SERDE-15, SERDE-19, SERDE-20 (P7-9). Design §7.3 puts the Absent-key omission in each model's own
+# #dexpace_dump; the port puts it in this walk instead, because SERDE-19 is a MUST whose named
+# failure -- "absent this wiring, Absent and Null become indistinguishable on the wire" -- is
+# exactly what a per-model convention produces when one model forgets. In the walk it is
+# structural, which is the word §7.3 itself uses for what SERDE-19 needs.
+class DexpaceSerdeNativeTest < DexpaceTestCase
+ S = Dexpace::Serde
+ T = S::Tristate
+
+ # A PATCH model whose fields are tri-state: the walk, not this method, drops an Absent key.
+ class Patch
+ def initialize(name:, nick:)
+ @name = name
+ @nick = nick
+ end
+
+ def dexpace_dump = { "name" => @name, "nick" => @nick }
+ end
+
+ # A model whose dump nests another model, so the re-walk is exercised two levels deep.
+ class Owner
+ def initialize(pet) = @pet = pet
+ def dexpace_dump = { "pet" => @pet, "tags" => %w[a] }
+ end
+
+ test "native scalars pass through" do
+ assert_nil(S::Native.of(nil))
+ assert_equal([true, false, 1, 1.5, "é"], S::Native.of([true, false, 1, 1.5, "é"]))
+ assert_equal(::Encoding::UTF_8, S::Native.of("é").encoding, "nothing is retagged")
+ end
+
+ test "anything answering #dexpace_dump is replaced and re-walked, recursively" do
+ assert_equal({ "name" => "x", "nick" => nil }, S::Native.of(Patch.new(name: "x", nick: T::NULL)))
+ assert_equal({ "pet" => { "name" => "x" }, "tags" => %w[a] },
+ S::Native.of(Owner.new(Patch.new(name: "x", nick: T::ABSENT))),)
+ end
+
+ # SERDE-15: Absent MUST omit the key entirely; Null MUST emit the key with a wire null; Present
+ # MUST emit the key with the encoded inner value.
+ test "SERDE-15: Absent omits the key, Null emits a null, Present emits the value" do
+ assert_equal({ "name" => "x" }, S::Native.of(Patch.new(name: "x", nick: T::ABSENT)))
+ assert_equal({ "name" => "x", "nick" => nil }, S::Native.of(Patch.new(name: "x", nick: T::NULL)))
+ assert_equal({ "name" => "x", "nick" => "n" },
+ S::Native.of(Patch.new(name: "x", nick: T.present("n"))),)
+ end
+
+ test "SERDE-15: a Present holding a model is re-walked, so a nested Absent is dropped too" do
+ inner = Patch.new(name: "y", nick: T::ABSENT)
+
+ assert_equal({ "name" => "x", "nick" => { "name" => "y" } },
+ S::Native.of(Patch.new(name: "x", nick: T.present(inner))),)
+ end
+
+ # SERDE-20: degrade gracefully where no enclosing object can omit a key.
+ test "SERDE-20: a top-level Absent and Null both render null rather than throwing" do
+ assert_nil(S::Native.of(T::ABSENT))
+ assert_nil(S::Native.of(T::NULL))
+ assert_nil(S::Native.of(S::OMIT))
+ end
+
+ test "SERDE-20: an array element Absent becomes null rather than being dropped" do
+ assert_equal([nil, nil, "v"], S::Native.of([T::ABSENT, T::NULL, T.present("v")]))
+ assert_equal(3, S::Native.of([T::ABSENT, T::ABSENT, T::ABSENT]).length, "positions are kept")
+ end
+
+ # Verified fact 3 is why this test exists: ::JSON.generate(Object.new) returns
+ # "\"#\"" rather than raising, so SERDE-9/SERDE-10's "an unserializable value throws
+ # the serialization subtype" is a requirement the generator alone silently fails.
+ test "an unserializable value raises SerializationError NAMING THE CLASS" do
+ error = assert_raises(Dexpace::Serde::SerializationError) { S::Native.of(Object.new) }
+
+ assert_match(/Object/, error.message)
+ assert_kind_of(Dexpace::Serde::Error, error)
+ assert_raises(Dexpace::Serde::SerializationError) { S::Native.of(:symbol) }
+ assert_raises(Dexpace::Serde::SerializationError) { S::Native.of([1, [2, Object.new]]) }
+ end
+
+ test "Hash keys are Strings or Symbols, coerced to Strings, and anything else raises" do
+ assert_equal({ "a" => 1, "b" => 2 }, S::Native.of({ "a" => 1, b: 2 }))
+ error = assert_raises(Dexpace::Serde::SerializationError) { S::Native.of({ Object.new => 1 }) }
+
+ assert_match(/key/, error.message)
+ assert_raises(Dexpace::Serde::SerializationError) { S::Native.of({ 1 => 1 }) }
+ end
+
+ test "the encoders table is the ONE hook, and core ships it empty" do
+ walked = S::Native.of(::Time.utc(2026, 9, 10), encoders: { ::Time => :iso8601.to_proc })
+
+ assert_equal("2026-09-10T00:00:00Z", walked)
+ assert_raises(Dexpace::Serde::SerializationError) { S::Native.of(::Time.utc(2026, 9, 10)) }
+ end
+
+ test "an encoder's result is re-walked, and a subclass finds its superclass's encoder" do
+ encoders = { ::Numeric => ->(n) { { "n" => n.to_s } }, ::Time => ->(t) { [t.to_i, T::ABSENT] } }
+
+ assert_equal({ "n" => "1/2" }, S::Native.of(Rational(1, 2), encoders: encoders))
+ assert_equal([0, nil], S::Native.of(::Time.at(0), encoders: encoders))
+ end
+
+ test "a value answering #dexpace_dump wins over an encoder for its class" do
+ patch = Patch.new(name: "x", nick: T::ABSENT)
+
+ assert_equal({ "name" => "x" }, S::Native.of(patch, encoders: { Patch => ->(_) { "encoded" } }))
+ end
+
+ test "the walk returns fresh collections and never aliases the caller's" do
+ source = { "a" => [1] }
+ walked = S::Native.of(source)
+
+ refute_same(source, walked)
+ refute_same(source["a"], walked["a"])
+ assert_equal({ "a" => [1] }, source)
+ end
+
+ test "a mutable String is copied and frozen on the way out; a frozen one passes as it is" do
+ mutable = +"x"
+ frozen = "y"
+
+ refute_same(mutable, S::Native.of(mutable))
+ assert_predicate(S::Native.of(mutable), :frozen?)
+ assert_same(frozen, S::Native.of(frozen))
+ end
+
+ test "OMIT is a frozen sentinel with a stable textual form and a class a caller cannot name" do
+ assert_predicate(S::OMIT, :frozen?)
+ assert_equal("Omit", S::OMIT.to_s)
+ assert_equal("Omit", S::OMIT.inspect)
+ refute_includes(S.constants(false), :Omit)
+ end
+
+ test "encoders: must be a Hash of Class to callable" do
+ assert_raises(Dexpace::InvalidArgumentError) { S::Native.of(1, encoders: nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::Native.of(1, encoders: { "Time" => ->(t) { t } }) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::Native.of(1, encoders: { ::Time => :iso8601 }) }
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/no_concrete_codec_test.rb b/gems/dexpace-core/test/dexpace/serde/no_concrete_codec_test.rb
new file mode 100644
index 0000000..26e4d86
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/no_concrete_codec_test.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+require "ripper"
+
+# SEAM-2, and the phase-7 charter's cross-cutting constraint: "Dexpace::Serde::JSON appears in no
+# core file, in no core sig/ file (NFR-11) and in no core test." No other gate sees this inside one
+# gem -- the require allowlist denies `json` by name but says nothing about a CONSTANT reference,
+# and Steep type-checks core against core's own sig/ where the constant does not exist either.
+#
+# The scan reads CODE, not comments: lib/dexpace/serde.rb and lib/dexpace/http/method.rb both
+# name the adapter in a comment explaining the shadowing hazard, which is exactly the kind of
+# sentence a reader needs and not a dependency. Ruby files are tokenised with Ripper (stdlib on
+# every supported Ruby) and their comment tokens dropped; .rbs files drop `#` comment lines.
+class DexpaceSerdeNoConcreteCodecTest < DexpaceTestCase
+ ROOT = File.expand_path("../../..", __dir__)
+ TREES = %w[lib sig test].freeze
+ SELF = File.expand_path(__FILE__)
+
+ test "no core file, signature or test names the concrete codec outside a comment" do
+ offenders = TREES.flat_map do |tree|
+ Dir.glob(File.join(ROOT, tree, "**", "*")).select do |path|
+ File.file?(path) && File.expand_path(path) != SELF && code_of(path).include?("Serde::JSON")
+ end
+ end
+
+ assert_empty(offenders, "SEAM-2: core must name no concrete codec")
+ end
+
+ test "the seam itself supplies no media type to fall back to" do
+ refute_respond_to(Dexpace::Serde, :media_type, "SEAM-19, restated at phase 7's first consumer")
+ end
+
+ private
+
+ def code_of(path)
+ source = File.read(path)
+ return source.lines.grep_v(/\A\s*#/).join if path.end_with?(".rbs")
+ return source unless path.end_with?(".rb")
+
+ Ripper.lex(source).reject { |(_, type, _)| type == :on_comment }.map { |token| token[2] }.join
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/nullable_test.rb b/gems/dexpace-core/test/dexpace/serde/nullable_test.rb
new file mode 100644
index 0000000..f1de792
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/nullable_test.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-6, SERDE-8, SERDE-13, SERDE-20: the combinator that accepts a wire null where its element
+# witness would not. It is the witness-aware half of SERDE-13's repair -- #load screens nothing for
+# nil itself, because THIS is the witness that legitimately wants one.
+class DexpaceSerdeNullableTest < DexpaceTestCase
+ S = Dexpace::Serde
+
+ # A model class that is a witness, and one that refuses nil through the context.
+ class Pet
+ attr_reader :name
+
+ def self.dexpace_load(parsed, ctx)
+ h = ctx.object!(parsed)
+ new(ctx.string!(h["name"], key: "name"))
+ end
+
+ def initialize(name) = @name = name
+ end
+
+ def ctx = S::DecodeContext.root
+
+ test "SERDE-6: Nullable accepts nil where the element witness would not" do
+ assert_nil(S::Nullable.of(Pet).dexpace_load(nil, ctx))
+ assert_instance_of(Pet, S::Nullable.of(Pet).dexpace_load({ "name" => "x" }, ctx))
+ assert_raises(Dexpace::Serde::DeserializationError) { Pet.dexpace_load(nil, ctx) }
+ end
+
+ test "a non-nil value still goes through the element witness's strictness" do
+ assert_raises(Dexpace::Serde::DeserializationError) do
+ S::Nullable.of(Integer).dexpace_load("5", ctx)
+ end
+ assert_in_delta(1.0, S::Nullable.of(Float).dexpace_load(1, ctx))
+ end
+
+ test "SERDE-8: Nullable cannot be built from a non-witness" do
+ assert_raises(Dexpace::InvalidArgumentError) { S::Nullable.of(Object.new) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::Nullable.of(nil) }
+ end
+
+ test "a nullable is a frozen value with structural equality, built through .build like a model" do
+ assert_equal(S::Nullable.of(Pet), S::Nullable.of(Pet))
+ assert_equal(S::Nullable.of(Pet), S::Nullable.build(element: Pet))
+ assert_predicate(S::Nullable.of(Pet), :frozen?)
+ refute_respond_to(S::Nullable, :new)
+ refute_respond_to(S::Nullable, :[])
+ assert(S.witness?(S::Nullable.of(Pet)))
+ end
+
+ test "a nullable element inside a list turns a null element into nil rather than a failure" do
+ assert_equal(["a", nil], S::List.of(S::Nullable.of(String)).dexpace_load(["a", nil], ctx))
+ assert_raises(Dexpace::Serde::DeserializationError) do
+ S::List.of(String).dexpace_load(["a", nil], ctx)
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/scalars_test.rb b/gems/dexpace-core/test/dexpace/serde/scalars_test.rb
new file mode 100644
index 0000000..e867666
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/scalars_test.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-6, SERDE-21, SERDE-22: the scalar witnesses behind the ergonomic spellings
+# `List.of(String)`, `Map.of(String, Pet)` and `Tristate.of(Float)`, plus the one public constant --
+# Dexpace::Serde::BOOLEAN, a NAMED witness rather than two class keys, because Ruby has no Boolean
+# class to key on and `List.of(TrueClass)` would read as a list of `true`s (the plan's resolved
+# question 1). The table is a private_constant and is asserted here through the combinators that
+# consult it; the file mirrors lib/dexpace/serde/scalars.rb.
+class DexpaceSerdeScalarsTest < DexpaceTestCase
+ S = Dexpace::Serde
+
+ def ctx = S::DecodeContext.root
+
+ test "BOOLEAN is a public frozen witness with a stable textual form" do
+ assert(S.witness?(S::BOOLEAN))
+ assert_predicate(S::BOOLEAN, :frozen?)
+ assert_equal("Boolean", S::BOOLEAN.to_s)
+ assert_equal("Boolean", S::BOOLEAN.inspect)
+ end
+
+ test "BOOLEAN accepts exactly true and false and refuses the SERDE-21 coercions" do
+ assert(S::BOOLEAN.dexpace_load(true, ctx))
+ refute(S::BOOLEAN.dexpace_load(false, ctx))
+ assert_raises(Dexpace::Serde::DeserializationError) { S::BOOLEAN.dexpace_load("true", ctx) }
+ assert_raises(Dexpace::Serde::DeserializationError) { S::BOOLEAN.dexpace_load(1, ctx) }
+ assert_raises(Dexpace::Serde::DeserializationError) { S::BOOLEAN.dexpace_load(nil, ctx) }
+ end
+
+ test "String, Integer and Float resolve to scalar witnesses through every combinator" do
+ assert_equal(%w[a], S::List.of(::String).dexpace_load(%w[a], ctx))
+ assert_equal({ "a" => 1 }, S::Map.of(::String, ::Integer).dexpace_load({ "a" => 1 }, ctx))
+ assert_in_delta(2.0, S::Nullable.of(::Float).dexpace_load(2, ctx))
+ assert_equal(3, S::Tristate.of(::Integer).dexpace_load(3, ctx).value)
+ end
+
+ test "the same class resolves to the SAME scalar witness, so combinators stay equal" do
+ assert_equal(S::List.of(::String), S::List.of(::String))
+ assert_same(S::List.of(::String).element, S::List.of(::String).element)
+ end
+
+ test "SERDE-21/SERDE-22 through the scalar witnesses: strict, with the one widening" do
+ assert_raises(Dexpace::Serde::DeserializationError) { S::List.of(::Integer).dexpace_load(["5"], ctx) }
+ assert_raises(Dexpace::Serde::DeserializationError) { S::List.of(::String).dexpace_load([5], ctx) }
+ assert_equal([1.0], S::List.of(::Float).dexpace_load([1], ctx))
+ assert_equal([""], S::List.of(::String).dexpace_load([""], ctx))
+ end
+
+ # A class the table does not know must answer .dexpace_load like anything else -- ::Time is
+ # deliberately NOT mapped in core (the ISO-8601 wiring is the adapter's, design §3.4), and
+ # ::Symbol, ::Hash and ::Array are not witnesses either.
+ test "a class outside the table is not a witness by virtue of being a class" do
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(::Time) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(::Symbol) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(::Hash) }
+ assert_raises(Dexpace::InvalidArgumentError) { S::List.of(::TrueClass) }
+ end
+
+ test "the lookup table is not public API" do
+ refute_includes(S.constants(false), :Scalars)
+ assert_includes(S.constants(false), :BOOLEAN)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/status_aware_handler_test.rb b/gems/dexpace-core/test/dexpace/serde/status_aware_handler_test.rb
new file mode 100644
index 0000000..88d94db
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/status_aware_handler_test.rb
@@ -0,0 +1,268 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require_relative "../../support/fake_codec"
+require_relative "../../support/fake_response_body"
+require_relative "../../support/recovery_fixtures"
+require "dexpace"
+
+# SERDE-28. Its conformance clause names four cases and one of them is a trap: "2xx -> decode; 500
+# (AND A NON-CANONICAL 599) -> the mapped exception with the status code and a readable buffered
+# error body; 304 -> serde exception naming the status plus one close." 599 is a 5xx and therefore
+# the SECOND branch; a reader skimming "non-canonical" will file it under the third.
+#
+# The response is a REAL Dexpace::Response over 3b's FakeResponseBody, whose #closes counts RAW
+# close calls: a second close in the 4xx branch (which Body.buffer_bounded already closed) would
+# read 2 here, where a Closeable-latched body would hide it. Split under Metrics/ClassLength: the
+# 2xx and 4xx/5xx branches, the third branch, and the construction.
+class DexpaceSerdeStatusAwareHandlerTest < DexpaceTestCase
+ S = Dexpace::Serde
+
+ # Named, answering both protocol methods: .dexpace_load is what Dexpace::Serde.witness! requires
+ # and .call is what phase 2's FakeCodec#load drives (with BINARY bytes, hence the retag).
+ class UpcaseWitness
+ def self.dexpace_load(parsed, _ctx) = parsed.dup.force_encoding(::Encoding::UTF_8).upcase
+ def self.call(text) = dexpace_load(text, nil)
+ end
+
+ # The responses and handlers the nested cases share.
+ module Fixtures
+ include RecoveryFixtures
+
+ def body_of(text)
+ FakeResponseBody.new(Dexpace::IO::BufferedSource.of_bytes(text.b),
+ content_length: text.bytesize,)
+ end
+
+ def response_with(text, code: 200, headers: nil)
+ builder = Dexpace::Response.builder
+ builder.request = build_request
+ builder.protocol = Dexpace::Protocol::HTTP_1_1
+ builder.status = code
+ builder.headers = headers unless headers.nil?
+ builder.body = body_of(text)
+ builder.build
+ end
+
+ def handler(factory: nil)
+ kwargs = { serde: FakeCodec.new, witness: UpcaseWitness }
+ kwargs[:factory] = factory unless factory.nil?
+ S::StatusAwareHandler.build(**kwargs)
+ end
+ end
+
+ # The first two branches: 2xx decodes, 4xx/5xx raises the mapped error over the buffered body.
+ class MappedTest < DexpaceTestCase
+ include Fixtures
+
+ test "SERDE-28: a 2xx decodes through the SAME implementation as the plain handler" do
+ response = response_with("héllo", code: 200)
+
+ assert_equal("HÉLLO", handler.call(response))
+ assert_equal(1, response.body.closes)
+ assert_equal("HÉLLO", handler.call(response_with("héllo", code: 201)))
+ end
+
+ test "SERDE-28: a 2xx with no body raises the decoding handler's target-naming error" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) { handler.call(build_response(204)) }
+
+ assert_match(/UpcaseWitness/, error.message)
+ end
+
+ test "SERDE-28: a 4xx/5xx raises the mapped exception and never decodes the error payload" do
+ [400, 404, 500, 599].each do |code|
+ response = response_with("boom", code: code)
+
+ error = assert_raises(Dexpace::ProtocolError) { handler.call(response) }
+
+ assert_equal(code, error.status.code)
+ assert_nil(error.cause, "constructed by the factory, never chained to an in-flight error")
+ end
+ end
+
+ test "SERDE-28: the error payload never reaches the witness" do
+ touched = false
+ witness = Class.new do
+ define_singleton_method(:dexpace_load) { |_parsed, _ctx| touched = true }
+ define_singleton_method(:call) { |_text| touched = true }
+ end
+ built = S::StatusAwareHandler.build(serde: FakeCodec.new, witness: witness)
+
+ assert_raises(Dexpace::ProtocolError) { built.call(response_with("b", code: 500)) }
+ refute(touched)
+ end
+
+ # "carrying a bounded, buffered in-memory copy of the error body (so the error body is readable
+ # AFTER the live response closes)" -- and BODY-30's own words are "decode it, then snapshot it",
+ # which is why BufferBody#source hands out a fresh peek view per call (P3-23).
+ test "SERDE-28: the error body is readable twice after the live response is gone" do
+ live = response_with("boom", code: 500)
+ error = assert_raises(Dexpace::ProtocolError) { handler.call(live) }
+
+ assert_equal(1, live.body.closes, "the live response was released")
+ assert_instance_of(Dexpace::BufferBody, error.response.body)
+ assert_equal("boom", error.response.body_string)
+ assert_equal("boom", error.response.body_string)
+ assert_equal(500, error.response.status.code)
+ end
+
+ # RECOV-16's buffering already released the original in an ensure, so this branch must NOT
+ # close again -- a second close would be a double close of an object Body.buffer_bounded
+ # released, and FakeResponseBody's raw counter is what would show it.
+ test "the 4xx/5xx branch adds no second close of its own" do
+ response = response_with("boom", code: 500)
+
+ assert_raises(Dexpace::ProtocolError) { handler.call(response) }
+ assert_equal(1, response.body.closes)
+ end
+
+ test "SERDE-28: a 4xx with no body still raises the mapped error over the bodyless response" do
+ error = assert_raises(Dexpace::ProtocolError) { handler.call(build_response(404)) }
+
+ assert_equal(404, error.status.code)
+ assert_nil(error.response.body)
+ end
+
+ test "SERDE-28: the factory keyword substitutes a generated SDK's typed error" do
+ typed = Class.new(::StandardError)
+ calls = []
+ factory = lambda do |response|
+ calls << response
+ typed.new("status #{response.status.code}")
+ end
+
+ error = assert_raises(typed) { handler(factory: factory).call(response_with("b", code: 503)) }
+
+ assert_equal("status 503", error.message)
+ assert_equal(1, calls.size)
+ assert_instance_of(Dexpace::BufferBody, calls.first.body, "the factory sees the BUFFERED one")
+ end
+
+ test "a factory that returns something other than an Exception is a refused caller mistake" do
+ assert_raises(Dexpace::InvalidArgumentError) do
+ handler(factory: ->(_response) { :not_an_error }).call(response_with("b", code: 500))
+ end
+ end
+
+ test "TypedResponse takes it: a 4xx raised through #value is memoized as the same object" do
+ failing = response_with("boom", code: 500)
+ typed = Dexpace::TypedResponse.new(response: failing, handler: handler)
+ first = assert_raises(Dexpace::ProtocolError) { typed.value }
+ second = assert_raises(Dexpace::ProtocolError) { typed.value }
+
+ assert_same(first, second)
+ assert_equal("boom", first.response.body_string)
+ end
+ end
+
+ # The third branch. Its message MUST lead with the status code and preserve conditional/redirect
+ # context -- by COPYING the raw header values, never by parsing them: running a malformed server
+ # ETag through HTTP-48's validating helper inside an error path turns a diagnostic into a second
+ # failure (the charter's argument about the unbuilt HTTP-48 helper, honoured).
+ class UnhandledTest < DexpaceTestCase
+ include Fixtures
+
+ test "SERDE-28: a 304 closes and raises a serde exception leading with the code" do
+ headers = headers_with("etag", '"abc"').new_builder.add("location", "/x").build
+ response = response_with("", code: 304, headers: headers)
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) { handler.call(response) }
+
+ assert_match(/\A304\b/, error.message)
+ assert_match(/"abc"/, error.message)
+ assert_match(%r{/x}, error.message)
+ assert_match(/UpcaseWitness/, error.message)
+ assert_equal(1, response.body.closes)
+ end
+
+ test "SERDE-28: a malformed ETag reaches the message verbatim, never a second failure" do
+ response = response_with("", code: 304, headers: headers_with("etag", 'W/"unterminated'))
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) { handler.call(response) }
+
+ assert_match(%r{W/"unterminated}, error.message)
+ end
+
+ test "SERDE-28: a multi-valued Location is carried whole, joined as the header line reads" do
+ response = response_with("", code: 304, headers: headers_with("location", "/a", "/b"))
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) { handler.call(response) }
+
+ assert_match(%r{/a, /b}, error.message)
+ end
+
+ test "SERDE-28: a 1xx and an unfollowed 3xx with no conditional headers take this branch too" do
+ [100, 301, 307].each do |code|
+ response = response_with("", code: code)
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) { handler.call(response) }
+
+ assert_match(/\A#{code}\b/, error.message)
+ assert_equal(1, response.body.closes)
+ end
+ end
+
+ # An anonymous witness has no name for DecodeContext.root to carry (P7-70); the message names
+ # it in words rather than interpolating nothing (review round 1, R1-4).
+ test "SERDE-28: an anonymous witness is named as such in the third branch's message" do
+ anonymous = Class.new { def self.dexpace_load(parsed, ctx) = ctx.object!(parsed) }
+ built = S::StatusAwareHandler.build(serde: FakeCodec.new, witness: anonymous)
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ built.call(response_with("", code: 304))
+ end
+
+ assert_match(/\A304 Not Modified: not decoded into an anonymous witness,/, error.message)
+ refute_match(/#(r) { ::RuntimeError.new(r.status.code.to_s) })
+
+ refute_respond_to(S::StatusAwareHandler, :new)
+ refute_respond_to(S::StatusAwareHandler, :[])
+ assert_predicate(built, :frozen?)
+ assert_equal("HÉLLO", substitute.call(response_with("héllo")))
+ assert_raises(::RuntimeError) { substitute.call(response_with("b", code: 500)) }
+ end
+
+ test "a non-witness, a nil serde and a bad factory each fail at construction" do
+ assert_raises(Dexpace::InvalidArgumentError) do
+ S::StatusAwareHandler.build(serde: FakeCodec.new, witness: 5)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ S::StatusAwareHandler.build(serde: nil, witness: UpcaseWitness)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ S::StatusAwareHandler.build(serde: FakeCodec.new, witness: UpcaseWitness, factory: nil)
+ end
+ assert_raises(Dexpace::InvalidArgumentError) do
+ S::StatusAwareHandler.build(serde: FakeCodec.new, witness: UpcaseWitness,
+ factory: Object.new,)
+ end
+ end
+
+ test "anything but a Dexpace::Response is refused" do
+ assert_raises(Dexpace::InvalidArgumentError) { handler.call(nil) }
+ assert_raises(Dexpace::InvalidArgumentError) { handler.call("200") }
+ end
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/tristate_decode_test.rb b/gems/dexpace-core/test/dexpace/serde/tristate_decode_test.rb
new file mode 100644
index 0000000..dcbe9e6
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/tristate_decode_test.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-16 and SERDE-17. The asymmetry SERDE-17 documents does not bite in Ruby: JSON.parse yields
+# an ordinary Hash and hash.key?("x") distinguishes an absent key from a present null directly
+# (serde/5fe8e3ed, verified), so the witness decides per key with full knowledge of the enclosing
+# model's shape. There is no field-default machinery and none is emulated.
+#
+# TWO entry points, and the reason is measurable: #dexpace_load_field needs the ENCLOSING Hash to
+# ask key?, while the protocol's #dexpace_load sees only the value and cannot. The second is
+# SERDE-20's top-level case. An extra suite beside tristate_test.rb, the file's mirror, because
+# the decode half is a different concern from the value type.
+class DexpaceSerdeTristateDecodeTest < DexpaceTestCase
+ S = Dexpace::Serde
+ T = S::Tristate
+
+ def ctx = S::DecodeContext.root
+
+ # SERDE-16's own conformance clause: decode {}, {"x":null}, {"x":value}.
+ test "SERDE-16/SERDE-17: a missing key is Absent, an explicit null is Null, a value is Present" do
+ w = T.of(String)
+
+ assert_same(T::ABSENT, w.dexpace_load_field({}, "x", ctx))
+ assert_same(T::NULL, w.dexpace_load_field({ "x" => nil }, "x", ctx))
+ assert_equal("v", w.dexpace_load_field({ "x" => "v" }, "x", ctx).value)
+ end
+
+ # SERDE-17's conformance clause names the trap by name: "assert Absent, NOT Null".
+ test "SERDE-17: an omitted field is Absent and never Null" do
+ result = T.of(String).dexpace_load_field({}, "x", ctx)
+
+ assert_predicate(result, :absent?)
+ refute_predicate(result, :null?)
+ end
+
+ test "SERDE-16: the inner value's declared element type is preserved" do
+ assert_instance_of(::Float, T.of(Float).dexpace_load_field({ "x" => 1 }, "x", ctx).value)
+ assert_raises(Dexpace::Serde::DeserializationError) do
+ T.of(Integer).dexpace_load_field({ "x" => "5" }, "x", ctx)
+ end
+ end
+
+ test "the field's shape failure names the field's own path" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ T.of(Integer).dexpace_load_field({ "x" => "5" }, "x", ctx.at("pet"))
+ end
+
+ assert_equal("expected Integer at /pet/x, got String", error.message)
+ end
+
+ test "#dexpace_load_field refuses a non-Hash enclosing value through the context" do
+ assert_raises(Dexpace::Serde::DeserializationError) do
+ T.of(String).dexpace_load_field([], "x", ctx)
+ end
+ end
+
+ # SERDE-20's decode half: "deserialize a top-level null -> Null".
+ test "SERDE-20: a top-level null decodes to Null through the protocol entry point" do
+ assert_same(T::NULL, T.of(String).dexpace_load(nil, ctx))
+ assert_equal("v", T.of(String).dexpace_load("v", ctx).value)
+ end
+
+ test "SERDE-8: Tristate.of rejects a non-witness like every other combinator" do
+ assert_raises(Dexpace::InvalidArgumentError) { T.of(Object.new) }
+ assert_raises(Dexpace::InvalidArgumentError) { T.of(nil) }
+ end
+
+ test "Tristate.of and Tristate.from_nullable are different things with different names" do
+ assert(S.witness?(T.of(String)))
+ refute(S.witness?(T.from_nullable("v")))
+ end
+
+ test "the combinator is a frozen value with structural equality and nests" do
+ assert_equal(T.of(String), T.of(String))
+ assert_predicate(T.of(String), :frozen?)
+ assert_predicate(T.of(S::List.of(String)).dexpace_load(%w[a], ctx), :present?)
+ end
+
+ test "the combinator's class is not public API: it is reached through .of alone" do
+ refute_includes(T.constants(false), :Combinator)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/tristate_test.rb b/gems/dexpace-core/test/dexpace/serde/tristate_test.rb
new file mode 100644
index 0000000..209ef28
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/tristate_test.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-14, SERDE-18, SERDE-30. Three states and no fourth: Present is bounded to non-null so
+# Present-of-null is unrepresentable through the public API. The module is included by all three
+# values, exactly Outcome's shape (4b) and Context's (4a), so v.is_a?(Tristate) is one type test.
+class DexpaceSerdeTristateTest < DexpaceTestCase
+ T = Dexpace::Serde::Tristate
+
+ test "all three values share the module, so one type test covers them" do
+ assert_kind_of(T, T::ABSENT)
+ assert_kind_of(T, T::NULL)
+ assert_kind_of(T, T.present(1))
+ end
+
+ # SERDE-14: the illegal fourth state, closed on the CONSTRUCTION path.
+ test "SERDE-14: Present rejects nil at construction" do
+ error = assert_raises(Dexpace::InvalidArgumentError) { T.present(nil) }
+
+ assert_equal("value is required", error.message)
+ assert_raises(Dexpace::InvalidArgumentError) { T::Present.build(value: nil) }
+ end
+
+ # SERDE-14 on the two generated constructors: Data.define makes .new AND .[] and both are
+ # private, so `Present[value: nil]` is not a fourth state either; the validation lives in
+ # #initialize, so every construction path -- .build, and the send-past-private hole P8 records
+ # -- passes through it.
+ test "SERDE-14: .new and .[] are private, and #initialize validates for every path" do
+ refute_respond_to(T::Present, :new)
+ refute_respond_to(T::Present, :[])
+ assert_raises(::NoMethodError) { T::Present[value: nil] }
+ assert_raises(Dexpace::InvalidArgumentError) { T::Present.send(:new, value: nil) }
+ end
+
+ # SERDE-14: and closed on the DERIVATION path, which is the half a reader will not think to test.
+ # Model#with routes through .build on every supported Ruby (data-modeling/83610619) -- Data#with
+ # does NOT call an initialize override on 3.2, so without Model this would silently succeed there.
+ test "SERDE-14: #with cannot derive a Present holding nil, on any supported Ruby" do
+ assert_raises(Dexpace::InvalidArgumentError) { T.present(1).with(value: nil) }
+ assert_equal(2, T.present(1).with(value: 2).value)
+ end
+
+ test "SERDE-18: the three factories" do
+ assert_same(T::ABSENT, T.absent)
+ assert_same(T::NULL, T.null)
+ assert_equal(1, T.present(1).value)
+ assert_same(false, T.present(false).value, "false is a value, not an absence")
+ end
+
+ # SERDE-18's own conformance clause, quoted: "assert the nullable mapper yields Present for
+ # non-null and Null for null". It can NEVER yield Absent, which is why it is a separate name from
+ # Tristate.of -- the combinator.
+ test "SERDE-18: from_nullable yields Present or Null and never Absent" do
+ assert_predicate(T.from_nullable(1), :present?)
+ assert_equal(1, T.from_nullable(1).value)
+ assert_predicate(T.from_nullable(nil), :null?)
+ refute_predicate(T.from_nullable(nil), :absent?)
+ assert_same(T::NULL, T.from_nullable(nil))
+ end
+
+ test "SERDE-18: the three predicates are exhaustive and mutually exclusive" do
+ [T::ABSENT, T::NULL, T.present(1)].each do |value|
+ assert_equal(1, [value.absent?, value.null?, value.present?].count(true), value.to_s)
+ end
+ assert_predicate(T::ABSENT, :absent?)
+ assert_predicate(T::NULL, :null?)
+ assert_predicate(T.present(1), :present?)
+ end
+
+ test "SERDE-18: the three-way fold" do
+ fold = ->(v) { v.fold(on_absent: -> { :a }, on_null: -> { :n }, on_present: ->(x) { x }) }
+
+ assert_equal(:a, fold.call(T::ABSENT))
+ assert_equal(:n, fold.call(T::NULL))
+ assert_equal(7, fold.call(T.present(7)))
+ end
+
+ test "SERDE-18: the value-or-null accessor" do
+ assert_nil(T::ABSENT.value_or_nil)
+ assert_nil(T::NULL.value_or_nil)
+ assert_equal(7, T.present(7).value_or_nil)
+ end
+
+ # SERDE-30 (MAY, taken). Ruby's default #inspect for a singleton renders its object id, so a log
+ # line or a test failure comparing tristates would otherwise differ between runs. Asserted as
+ # string equality, not as refute_match(/0x/), because the requirement names the forms.
+ test "SERDE-30: the sentinels have stable identity-free textual forms" do
+ assert_equal("Absent", T::ABSENT.to_s)
+ assert_equal("Absent", T::ABSENT.inspect)
+ assert_equal("Null", T::NULL.to_s)
+ assert_equal("Null", T::NULL.inspect)
+ end
+
+ test "the sentinels are frozen singletons of classes a caller cannot name" do
+ assert_predicate(T::ABSENT, :frozen?)
+ assert_predicate(T::NULL, :frozen?)
+ assert_same(T::ABSENT, T.absent)
+ refute_equal(T::ABSENT, T::NULL)
+ refute_includes(T.constants(false), :Absent)
+ refute_includes(T.constants(false), :Null)
+ end
+
+ test "Present compares by value and is frozen" do
+ assert_equal(T.present(1), T.present(1))
+ refute_equal(T.present(1), T.present(2))
+ assert_predicate(T.present(1), :frozen?)
+ assert_equal(T.present(1).hash, T.present(1).hash)
+ end
+
+ # The encode half every value answers (SERDE-15/SERDE-20, cashed in by Native): Absent dumps to
+ # the OMIT sentinel the walk drops from a Hash, Null to nil, Present to its inner value.
+ test "#dexpace_dump: OMIT for Absent, nil for Null, the inner value for Present" do
+ assert_same(Dexpace::Serde::OMIT, T::ABSENT.dexpace_dump)
+ assert_nil(T::NULL.dexpace_dump)
+ assert_equal("v", T.present("v").dexpace_dump)
+ end
+
+ test "the module has no factory of its own for a Present holding nothing" do
+ assert_equal(%i[absent from_nullable null of present].sort,
+ (T.singleton_methods - Module.instance_methods).sort,)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde/witness_test.rb b/gems/dexpace-core/test/dexpace/serde/witness_test.rb
new file mode 100644
index 0000000..f547a93
--- /dev/null
+++ b/gems/dexpace-core/test/dexpace/serde/witness_test.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../test_helper"
+require "dexpace"
+
+# SERDE-5, SERDE-7, SERDE-8. Ruby erases nothing but reifies no element types, so the requirement is
+# live and its mechanism (a reflective type token) is unavailable -- design §10.14 substitutes a
+# class-object-and-combinator protocol. A witness is any object responding to .dexpace_load, which
+# covers a model CLASS and a combinator INSTANCE with one predicate (verified fact 15: respond_to?
+# sees a class method).
+class DexpaceSerdeWitnessTest < DexpaceTestCase
+ # The smallest model class that is a witness.
+ class Pet
+ def self.dexpace_load(parsed, ctx) = new(ctx.object!(parsed))
+ def initialize(hash) = @hash = hash
+ end
+
+ test "a class answering .dexpace_load is a witness" do
+ assert(Dexpace::Serde.witness?(Pet))
+ assert_same(Pet, Dexpace::Serde.witness!(Pet))
+ end
+
+ test "an ordinary object answering #dexpace_load is a witness too" do
+ combinator = Object.new
+ def combinator.dexpace_load(parsed, _ctx) = parsed
+
+ assert(Dexpace::Serde.witness?(combinator))
+ assert_same(combinator, Dexpace::Serde.witness!(combinator))
+ end
+
+ # SERDE-8: reject construction with no type argument, failing fast with an actionable message.
+ test "anything else is not a witness and witness! says what is missing" do
+ refute(Dexpace::Serde.witness?(Object.new))
+ refute(Dexpace::Serde.witness?(nil))
+ refute(Dexpace::Serde.witness?("not a witness"))
+ refute(Dexpace::Serde.witness?(::String), "a class with no .dexpace_load is not one either")
+
+ error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Serde.witness!(nil) }
+
+ assert_match(/dexpace_load/, error.message)
+ assert_match(/NilClass/, error.message)
+ end
+
+ # SERDE-5's witness is EXPLICIT and SERDE-8's construction fails FAST: a bare lambda answers #call
+ # and is not a witness, and widening the predicate to accept #call would make every lambda one
+ # and both MUSTs unenforceable. Phase 2's FakeCodec drives its witness through #call because it
+ # predates the protocol; a handler test therefore uses a named class answering both.
+ test "a #call-shaped object is NOT a witness: the protocol method is the one name" do
+ refute(Dexpace::Serde.witness?(->(parsed, _ctx) { parsed }))
+ assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Serde.witness!(->(text) { text }) }
+ end
+
+ # SERDE-7: the ergonomic route IS the generic carrier. There is no raw-class path beside it to
+ # forget to route through, because the class object is itself the witness.
+ test "the ergonomic spelling and the carrier spelling are the same object" do
+ assert_same(Pet, Dexpace::Serde.witness!(Pet))
+ end
+
+ test "the two protocol method names are public frozen symbols, and the predicate reads them" do
+ assert_equal(:dexpace_load, Dexpace::Serde::WITNESS_METHOD)
+ assert_equal(:dexpace_dump, Dexpace::Serde::DUMP_METHOD)
+ assert_predicate(Dexpace::Serde::WITNESS_METHOD, :frozen?)
+ assert_predicate(Dexpace::Serde::DUMP_METHOD, :frozen?)
+ end
+
+ test "the seam module still has no instance side: the predicates are singleton methods" do
+ assert_empty(Dexpace::Serde.instance_methods(false))
+ assert_respond_to(Dexpace::Serde, :witness?)
+ assert_respond_to(Dexpace::Serde, :witness!)
+ end
+end
diff --git a/gems/dexpace-core/test/dexpace/serde_test.rb b/gems/dexpace-core/test/dexpace/serde_test.rb
index 31d215c..9b81f7f 100644
--- a/gems/dexpace-core/test/dexpace/serde_test.rb
+++ b/gems/dexpace-core/test/dexpace/serde_test.rb
@@ -38,14 +38,27 @@ class DexpaceSerdeTest < DexpaceTestCase
assert_equal(%i[media_type], Dexpace::Serde.missing_methods(forgetful))
end
- test "the registry starts empty and its error names this seam and no gem" do
- assert_empty(Dexpace::Serde.registered_keys)
-
- error = assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve }
+ # A property of a process that required `dexpace` ALONE -- `rake test:gems` runs every gem's
+ # suite in one process and dexpace-serde-json registers itself under :json as its entry file
+ # loads (design §3.6), so this is asserted in a child process, the shape
+ # instrumentation/independence_test.rb uses. Converted by phase 7a as a pin its registration
+ # invalidated; the message assertions are unchanged.
+ BARE_REQUIRE = <<~RUBY
+ require "dexpace"
+ puts Dexpace::Serde.registered_keys.inspect
+ puts(Dexpace::Serde.resolve.then { "RESOLVED" }) rescue puts($!.message)
+ RUBY
+ GEM_ROOT = File.expand_path("../..", __dir__)
- assert_match(/no codec provider is registered/, error.message)
- assert_match(/Dexpace::Serde\.install/, error.message)
- refute_match(/json|oj/i, error.message, "SEAM-2")
+ test "the registry starts empty and its error names this seam and no gem" do
+ command = [::RbConfig.ruby, "-w", "-Ilib", "-e", BARE_REQUIRE]
+ out = IO.popen(command, err: %i[child out], chdir: GEM_ROOT, &:read)
+ keys, message = out.lines(chomp: true)
+
+ assert_equal("[]", keys, out)
+ assert_match(/no codec provider is registered/, message)
+ assert_match(/Dexpace::Serde\.install/, message)
+ refute_match(/json|oj/i, message, "SEAM-2")
end
test "install refuses a codec that does not implement the seam" do
@@ -56,6 +69,18 @@ class DexpaceSerdeTest < DexpaceTestCase
assert_match(/must implement the seam/, error.message)
end
+ # After the block the OVERRIDE is gone. What resolves then depends on the process: nothing, in
+ # a process that required core alone (a SeamError); the registered adapter's codec, under
+ # `rake test:gems`, where dexpace-serde-json has registered :json. Registry#swap restores
+ # `resolved` and deliberately never `factories` (a registration is a monotonic require-time
+ # fact), so the assertion is that the swapped-in codec is no longer what resolves -- converted by
+ # phase 7a from "nothing resolves", a pin its registration invalidated.
+ def resolved_after_swap
+ Dexpace::Serde.resolve
+ rescue Dexpace::SeamError
+ :unresolved
+ end
+
test "swap scopes a codec override to its block" do
codec = FakeCodec.new
@@ -63,7 +88,7 @@ class DexpaceSerdeTest < DexpaceTestCase
assert_same(codec, Dexpace::Serde.resolve)
end
- assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve }
+ refute_same(codec, resolved_after_swap)
end
test "install goes through the module, returns it, and is scoped by an enclosing swap" do
@@ -74,7 +99,7 @@ class DexpaceSerdeTest < DexpaceTestCase
assert_same(codec, Dexpace::Serde.resolve)
end
- assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve }
+ refute_same(codec, resolved_after_swap)
end
# Scoped inside a swap and cleaned out of the private registry afterwards, for the reason
diff --git a/gems/dexpace-serde-json/README.md b/gems/dexpace-serde-json/README.md
index c2f7b1a..fe97753 100644
--- a/gems/dexpace-serde-json/README.md
+++ b/gems/dexpace-serde-json/README.md
@@ -3,9 +3,14 @@
Part of the [dexpace Ruby SDK](../../README.md): an HTTP-client toolkit, not an HTTP client.
This gem is the wire-codec seam's reference implementation, over `json`.
-**Status: skeleton at `0.0.0`.** Nothing is published yet, and `lib/` holds the namespace and a
-`VERSION` constant and nothing else. The phase that fills it is named in the YARD block of
-`lib/dexpace/serde/json.rb`.
+**Status: built by phase 7a, at `0.0.0`, unpublished.** `lib/` holds `Dexpace::Serde::JSON::Codec`
+— the seam's six methods over one private `JSON::Coder` per instance — the two factories
+`Dexpace::Serde::JSON.default` (a fresh codec on every call) and `.build(options)` (over a five-key
+option allowlist), the `json >= 2.19.9` floor asserted at require time as `Dexpace::SeamError`, and
+the seam registration under `:json`. The as-built page is
+[`docs/sdk-documentation/serde.md`](../../docs/sdk-documentation/serde.md); the per-requirement
+proof is
+[`docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md`](../../docs/work/mvp/phase7/phase7a/2026-09-10-phase7a-serialization-checklist.md).
## Install
@@ -19,17 +24,40 @@ gem "dexpace-serde-json"
```ruby
require "dexpace/serde/json"
-puts Dexpace::Serde::JSON::VERSION # => "0.0.0"
+class Pet
+ attr_reader :name
+
+ def self.dexpace_load(parsed, ctx)
+ new(ctx.string!(ctx.object!(parsed)["name"], key: "name"))
+ end
+
+ def initialize(name) = @name = name
+ def dexpace_dump = { "name" => @name }
+end
+
+codec = Dexpace::Serde::JSON.default
+codec.dump_string(Pet.new("Ré")) # => "{\"name\":\"Ré\"}"
+source = Dexpace::IO::BufferedSource.of_bytes("{\"name\":\"Ré\"}".b)
+codec.load(source, Pet).name # => "Ré"
+Dexpace::Serde.registered_keys # => [:json]
```
+The witness protocol (`.dexpace_load` / `#dexpace_dump`), the decode context, the combinators, the
+`Tristate` PATCH type and the two response handlers are `dexpace-core`'s; this gem supplies the
+codec they run through.
+
## Depends on
-`dexpace-core`, and later `json >= 2.19.9` -- the one third-party gem `NFR-2` budgets for this
-adapter, declared by phase 7 with the codec that needs it. That floor lives in this gemspec and
-nowhere else.
+`dexpace-core`, and `json >= 2.19.9` -- the one third-party gem `NFR-2` budgets for this adapter.
+That floor lives in this gemspec and nowhere else: it is the first `json` with `JSON::Coder`, the
+per-instance engine the codec is built on, and the one carrying the 2026 advisories. An unbundled
+`require "dexpace/serde/json"` on a stock Ruby 3.3 or 3.4 activates the interpreter's default
+`json` (2.7.2 / 2.9.1), so the entry file asserts the floor itself rather than failing later inside
+the codec.
## Where to read next
+- `docs/sdk-documentation/serde.md` -- the layer and the codec, as built.
- `docs/sdk-documentation/architecture.md` -- how the gems compose and which one to install.
- `docs/sdk-design-ruby/02-gem-and-workspace-layout.md` -- the gem layout and the
zero-dependency invariant every gem here is built under.
diff --git a/gems/dexpace-serde-json/dexpace-serde-json.gemspec b/gems/dexpace-serde-json/dexpace-serde-json.gemspec
index 9bb0a15..1db9175 100644
--- a/gems/dexpace-serde-json/dexpace-serde-json.gemspec
+++ b/gems/dexpace-serde-json/dexpace-serde-json.gemspec
@@ -12,8 +12,7 @@ Gem::Specification.new do |spec|
spec.summary = "The reference wire codec for dexpace, over Ruby's json."
spec.description = <<~TEXT
The reference wire codec for the dexpace HTTP-client toolkit, implemented over Ruby's json
- gem. It depends on dexpace-core and, once the codec lands, on json >= 2.19.9 and nothing
- else.
+ gem. It depends on dexpace-core and on json >= 2.19.9 and nothing else.
TEXT
spec.homepage = "https://github.com/dexpace/ruby-sdk"
spec.license = "MIT"
@@ -32,7 +31,13 @@ Gem::Specification.new do |spec|
spec.add_dependency "dexpace-core", DexpaceVersions.core_constraint
- # NFR-2: dexpace-core plus at most one third-party gem. The third-party half of this
- # adapter's budget is declared by the phase that writes the code needing it (design P0-9);
- # `rake gates:gemspec_audit` enforces the whole budget either way.
+ # NFR-2: dexpace-core plus at most one third-party gem, and this is the one. The floor is the
+ # first json version with JSON::Coder -- the per-instance, freezable, thread-safe engine
+ # SERDE-26 and SERDE-29 rest on (phase 7a's P7-4) -- and the one carrying the 2026 advisories.
+ # THIS LINE IS THE ONLY PLACE IN THE REPOSITORY THAT FLOOR MAY BE STATED (CLAUDE.md's hard rule,
+ # design §3.4): core's require allowlist denies `json` by name, `rake gates:gemspec_audit`
+ # enforces the budget, and `rake gates:require_allowlist` permits `require "json"` under this
+ # gem's lib/ only because this line declares it. The entry file re-asserts the same number at
+ # require time for an unbundled consumer (P7-7).
+ spec.add_dependency "json", ">= 2.19.9"
end
diff --git a/gems/dexpace-serde-json/lib/dexpace/serde/json.rb b/gems/dexpace-serde-json/lib/dexpace/serde/json.rb
index c4ec0ae..7717f36 100644
--- a/gems/dexpace-serde-json/lib/dexpace/serde/json.rb
+++ b/gems/dexpace-serde-json/lib/dexpace/serde/json.rb
@@ -1,20 +1,83 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT
+require "json"
+require "dexpace"
+
require_relative "json/version"
module Dexpace
# Wire codecs: the serde seam's shipped implementations. The seam contract itself lives in
# dexpace-core.
module Serde
- # The reference wire codec, over Ruby's `json` default gem. Phase 0 ships the namespace and
- # VERSION only; the codec itself lands in phase 7.
+ # The reference wire codec, over Ruby's `json` gem: the seam's six methods on
+ # Dexpace::Serde::JSON::Codec, the two factories here, the ISO-8601 encoder default and the
+ # tri-state wiring inherited from core's Native walk, and the `json >= 2.19.9` floor -- declared
+ # in this gem's gemspec, the only place it may be stated, and re-asserted below at require time.
#
# CAUTION: this module shadows ::JSON inside its own namespace. An unqualified `JSON.parse`
# written anywhere under `Dexpace::Serde::JSON` resolves to this module, not to Ruby's, and
# fails with a confusing NoMethodError. Every reference to Ruby's JSON from inside here is
- # written `::JSON`.
+ # written `::JSON`, and the Dexpace/QualifiedCoreConstant cop refuses the bare spelling.
module JSON
+ # The floor this gem declares in its gemspec and asserts at require time (P7-7). It is the
+ # first json with JSON::Coder -- the per-instance, freezable, thread-safe engine SERDE-26's
+ # private copy and SERDE-29's sharing rest on -- and the one carrying the 2026 advisories.
+ # `bundler-audit` enforces the floor for a BUNDLED consumer in this repository's CI; it never
+ # runs in a consumer's process, and an unbundled `require "dexpace/serde/json"` on a stock
+ # Ruby 3.3 or 3.4 activates the interpreter's default json (2.7.2 / 2.9.1), which has no
+ # Coder at all, while a stock 4.0 activates 2.18.0, which has one and is still below the
+ # floor. Without this assertion the failure would be a NameError deep inside a codec, or no
+ # failure and an unpatched parser.
+ MINIMUM_JSON_VERSION = "2.19.9"
+
+ # The core this adapter was built against, as design §2.4's registration-time version-skew
+ # guard wants it: a two-segment pessimistic requirement, and never Dexpace::VERSION --
+ # phase 2's Registry accepts only `~> M.N` and raises on "0.0.0", and the running core's own
+ # version is a tautology. Public so a consumer debugging a skew failure can read the
+ # constraint; equal to the gemspec's dexpace-core requirement, which the suite asserts.
+ REQUIRED_CORE = "~> 0.0"
+
+ class << self
+ # SERDE-25's default-configuration factory: a FRESH, independently configured codec on
+ # every call, never a shared instance, so an application that reconfigures the codec it
+ # was handed cannot change the one another part of the process is using. This is also the
+ # registry's factory (design §3.6).
+ #
+ # @return [Dexpace::Serde::JSON::Codec]
+ def default = Codec.build
+
+ # A codec over caller options. One positional Hash rather than a `**` splat, as
+ # Dexpace::Model#with is spelled: Ruby passes keywords to a method declaring none as one
+ # positional Hash, so `JSON.build(max_nesting: 4)` reads as it should while the empty call
+ # allocates nothing (Dexpace/NoKeywordSplat) and an unknown key is the SDK's own
+ # InvalidArgumentError rather than a keyword error whose shape differs between json 2.19.9
+ # (which swallowed an unknown Coder option) and 3.0 (which refuses it).
+ #
+ # @param options [Hash{Symbol => Object}] see Codec.build
+ # @return [Dexpace::Serde::JSON::Codec]
+ # @raise [Dexpace::InvalidArgumentError] on an unknown option
+ def build(options = nil) = Codec.build(options)
+ end
end
end
end
+
+# P7-7: the floor, asserted before the codec is even loaded, naming itself. A Gem::Version
+# comparison needs no require. This runs at the TOP LEVEL, outside `module Dexpace`, so a bare
+# `JSON` here is Ruby's (the shadowing hazard is lexical) and the cop set wants it unqualified.
+if Gem::Version.new(JSON::VERSION) < Gem::Version.new(Dexpace::Serde::JSON::MINIMUM_JSON_VERSION)
+ raise Dexpace::SeamError,
+ "dexpace-serde-json requires json >= #{Dexpace::Serde::JSON::MINIMUM_JSON_VERSION}; " \
+ "json #{JSON::VERSION} is active. Add `gem \"json\", \">= " \
+ "#{Dexpace::Serde::JSON::MINIMUM_JSON_VERSION}\"` to the bundle, or activate it before " \
+ "requiring this gem."
+end
+
+require_relative "json/codec"
+
+# Design §3.6's require-time self-registration, with the version-skew guard on `core:` (design
+# §2.4) -- spent here for the first time by an adapter with a third-party dependency. The factory
+# is `.default`, so every resolution is a fresh codec (SERDE-25).
+Dexpace::Serde.register(:json, -> { Dexpace::Serde::JSON.default },
+ core: Dexpace::Serde::JSON::REQUIRED_CORE,)
diff --git a/gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb b/gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb
new file mode 100644
index 0000000..23bcb89
--- /dev/null
+++ b/gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb
@@ -0,0 +1,291 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require "json"
+require "date"
+require "time"
+require "dexpace"
+
+module Dexpace
+ module Serde
+ module JSON
+ # The seam's six methods over one private ::JSON::Coder (SEAM-19–SEAM-21, SERDE-1–SERDE-4,
+ # SERDE-9–SERDE-12, SERDE-25, SERDE-26, SERDE-29). A plain class, not a Data: it holds an
+ # engine that is an implementation detail with no value semantics. Frozen at the end of
+ # `#initialize`, `.new` private, `.build` the one constructor and `.default` a FRESH instance
+ # per call (SERDE-25).
+ #
+ # SERDE-26 is satisfied literally rather than through §11.18's fallback clause (P7-4): the
+ # constructor takes OPTIONS, never a caller's coder, so "built around a caller-supplied codec
+ # instance" never happens, and each instance owns a private ::JSON::Coder built from its own
+ # options -- json 2.19.9's per-instance, freezable, thread-safe engine (verified), which is
+ # also what makes one frozen codec safe to share across workers with no per-type cache to
+ # publish (SERDE-29): the witness is supplied per call and nothing is memoised by type.
+ #
+ # The options are validated against a frozen allowlist -- `max_nesting`, `allow_nan`,
+ # `allow_duplicate_key`, `script_safe` and `encoders` -- and an unknown key is the SDK's own
+ # InvalidArgumentError, because the library's own behaviour differs across the supported
+ # range: json 2.19.9 SWALLOWS an unknown Coder option (a forwarded typo silently configures a
+ # different codec) and json 3.0 refuses it with a keyword error. Two options are set by this
+ # class and not by the caller: `strict: true` always, belt and braces beside core's Native
+ # walk, which refuses every non-native value before the generator sees it -- verified fact 3
+ # is that ::JSON.generate otherwise stringifies an Object silently; and
+ # `allow_duplicate_key: false` unless the caller opts in, because a duplicate key is
+ # accepted-last-wins on json 2.9, a WARNING on 2.19.9 and a ParserError on 3.0, and the
+ # explicit option makes it one DeserializationError across the range. `encoders:` never
+ # reaches the Coder (json 3.0 refuses the keyword): it replaces the default encoder table,
+ # which renders ::Time, ::DateTime and ::Date as ISO-8601 through core's Instant witness
+ # (SERDE-24; design §3.4 makes the wiring the adapter's, not core's).
+ #
+ # Every reference to Ruby's JSON is `::JSON`: inside this namespace a bare `JSON` is the
+ # adapter module (phase 0's shadowing caution; Dexpace/QualifiedCoreConstant enforces it).
+ # `::JSON::Coder#load` is `::JSON.parse`'s configured form and is NOT `::JSON.load`, which
+ # design §3.4 bans for its `create_additions` hazard (CVE-2020-10663); the two share four
+ # letters and nothing else.
+ class Codec
+ # The option validation, kept beside the class it serves: the allowlist, and the shape of
+ # `encoders:`. Private, and not a second public constant in this file.
+ module Options
+ extend self
+
+ # The options a caller may pass (see the class comment for why an allowlist).
+ ALLOWED = %i[max_nesting allow_nan allow_duplicate_key script_safe encoders].freeze
+
+ # @param options [Hash, nil]
+ # @return [Hash{Symbol => Object}] the options, or an empty Hash for nil
+ # @raise [Dexpace::InvalidArgumentError] on a non-Hash or an unknown key
+ def table!(options)
+ return {} if options.nil?
+ unless options.is_a?(::Hash)
+ raise InvalidArgumentError, "options must be a Hash, got #{options.class}"
+ end
+
+ unknown = options.keys.reject { |key| ALLOWED.include?(key) }
+ return options if unknown.empty?
+
+ raise InvalidArgumentError,
+ "unknown codec option(s) #{unknown.map(&:inspect).join(", ")}; the accepted " \
+ "options are #{ALLOWED.map(&:inspect).join(", ")}"
+ end
+
+ # @param encoders [Hash{Module => #call}]
+ # @return [Hash{Module => #call}] a frozen copy
+ # @raise [Dexpace::InvalidArgumentError] on anything else
+ def encoders!(encoders)
+ valid = encoders.is_a?(::Hash) &&
+ encoders.all? { |k, v| k.is_a?(::Module) && v.respond_to?(:call) }
+ unless valid
+ raise InvalidArgumentError,
+ "encoders must be a Hash of Class to #call, got #{encoders.class}"
+ end
+
+ encoders.frozen? ? encoders : encoders.dup.freeze
+ end
+ end
+ private_constant :Options
+
+ # SERDE-24's adapter default: date and time values as ISO-8601 strings through core's
+ # Instant, never epoch numbers. Exact-class lookup finds DateTime before its superclass
+ # Date. Replaceable through `encoders:`.
+ DEFAULT_ENCODERS = {
+ ::Time => ->(time) { Instant.dexpace_dump(time) },
+ ::DateTime => ->(datetime) { Instant.dexpace_dump(datetime.to_time) },
+ ::Date => :iso8601.to_proc,
+ }.freeze
+ private_constant :DEFAULT_ENCODERS
+
+ # SEAM-19/SERDE-2: the media type this codec produces, one frozen value.
+ MEDIA_TYPE = MediaType.parse("application/json")
+ private_constant :MEDIA_TYPE
+
+ private_class_method :new
+
+ # The one constructor. One positional Hash rather than a `**` splat, as Dexpace::Model#with
+ # is spelled: Ruby passes keywords to a method declaring none as one positional Hash, so
+ # `Codec.build(max_nesting: 4)` reads as it should while the empty call allocates nothing
+ # (Dexpace/NoKeywordSplat) and validation stays this class's.
+ #
+ # @param options [Hash{Symbol => Object}, nil] `max_nesting:` (Integer), `allow_nan:`,
+ # `allow_duplicate_key:`, `script_safe:` (booleans), `encoders:` (a Hash of Class to
+ # `#call(value) -> native`, replacing the ISO-8601 default table); a nil value means the
+ # default
+ # @return [Codec] frozen
+ # @raise [Dexpace::InvalidArgumentError] on a non-Hash, an unknown or non-Symbol key, or an
+ # `encoders:` that is not a Hash of Class to callable
+ def self.build(options = nil)
+ new(options)
+ end
+
+ # SERDE-25: a fresh, independently configured instance on every call.
+ #
+ # @return [Codec]
+ def self.default = build
+
+ def initialize(options)
+ table = Options.table!(options)
+ @encoders = Options.encoders!(table.fetch(:encoders, nil) || DEFAULT_ENCODERS)
+ # allow_duplicate_key: false unless the caller says otherwise; strict: true always.
+ json_options = { allow_duplicate_key: false }.merge(table.except(:encoders).compact)
+ @coder = ::JSON::Coder.new(**json_options, strict: true)
+ freeze
+ end
+
+ # SEAM-19/SERDE-2: `application/json`, as a Dexpace::MediaType.
+ #
+ # @return [Dexpace::MediaType]
+ def media_type = MEDIA_TYPE
+
+ # SEAM-20's string profile: the value walked to native form by core's Native (SERDE-15,
+ # SERDE-19, SERDE-20, SERDE-9's loud refusal) and generated as one UTF-8 String.
+ #
+ # @param value [Object]
+ # @return [String] UTF-8, fresh
+ # @raise [Dexpace::Serde::SerializationError] on an unencodable value, chaining the
+ # library's error when the generator raised (SERDE-9, SERDE-10)
+ def dump_string(value)
+ native = Native.of(value, encoders: @encoders)
+ text = @coder.dump(native)
+ text.force_encoding(::Encoding::UTF_8) unless text.encoding == ::Encoding::UTF_8
+ text
+ rescue ::JSON::JSONError => error
+ # Inside the rescue, so Ruby chains the library's error as #cause (SERDE-9).
+ raise SerializationError, "the value could not be encoded as JSON: #{error.message}"
+ end
+
+ # SEAM-20's byte-array profile: the same bytes, BINARY-tagged (§10.13).
+ #
+ # @param value [Object]
+ # @return [String] Encoding::BINARY, fresh
+ # @raise [Dexpace::Serde::SerializationError]
+ def dump_bytes(value) = dump_string(value).b
+
+ # SEAM-20's streaming profile: writes the bytes into a caller-owned sink and NEVER closes it
+ # (SERDE-3).
+ #
+ # @param value [Object]
+ # @param sink [#write] Dexpace::IO::_Sink-shaped
+ # @return [Integer] the byte count written
+ # @raise [Dexpace::Serde::SerializationError]
+ # @raise [Dexpace::InvalidArgumentError] when `sink` does not answer #write
+ def dump_to(value, sink)
+ unless sink.respond_to?(:write)
+ raise InvalidArgumentError, "sink must respond to #write, got #{sink.class}"
+ end
+
+ bytes = dump_bytes(value)
+ sink.write(bytes)
+ bytes.bytesize
+ end
+
+ # SEAM-20's buffer profile (SERDE-4): writes at `offset` into a mutable BINARY String and
+ # answers the byte count. A range failure is ::IndexError -- distinct from the serde type
+ # and chaining nothing -- and bytes outside the written region are untouched. The fit is
+ # checked EXPLICITLY, because `String#[]=` with an in-range offset and an over-long payload
+ # silently GROWS the target instead of raising (verified fact 12), which is the one
+ # behaviour SERDE-4 exists to forbid. The payload is encoded before the check because its
+ # length is not knowable otherwise, so a generator failure surfaces as SerializationError
+ # and an overflow as IndexError, in that order. Ruby's IO::Buffer is refused (P7-5): it
+ # warns through Warning.warn on construction at every level, and its `#set_string` raises
+ # ArgumentError where `String#[]=` raises IndexError.
+ #
+ # @param value [Object]
+ # @param buffer [String] mutable, Encoding::BINARY
+ # @param offset [Integer] the start position
+ # @return [Integer] the byte count written
+ # @raise [::IndexError] when `offset` is out of range or the payload does not fit; `#cause`
+ # is nil even when raised inside a caller's rescue
+ # @raise [Dexpace::Serde::SerializationError] on an unencodable value
+ # @raise [Dexpace::InvalidArgumentError] on a frozen, non-BINARY or non-String buffer, or a
+ # non-Integer offset -- a wrong KIND of argument, not a wrong range (3a's IO-3 precedent)
+ def dump_into(value, buffer, offset: 0)
+ buffer!(buffer, offset)
+ encoded = dump_bytes(value)
+ size = encoded.bytesize
+ if offset.negative? || offset > buffer.bytesize || offset + size > buffer.bytesize
+ # SERDE-4: distinct from the serde type and NOT chaining the caller's in-flight error
+ # (pipeline/7ce4431d's spelling; verified fact 11).
+ raise ::IndexError,
+ "#{size} bytes at offset #{offset} do not fit a #{buffer.bytesize}-byte buffer",
+ cause: nil
+ end
+
+ buffer[offset, size] = encoded
+ size
+ end
+
+ # SEAM-21's decode: drains the caller's source to EOF, validates the text as UTF-8, parses
+ # it with the private engine and hands the parsed value to the witness with a root
+ # DecodeContext naming it. Closes nothing (SERDE-3; design §10.12's third ownership rule,
+ # which phase 3 left to this layer).
+ #
+ # The source is read with 3a's `#read_utf8` -- unconditionally UTF-8, because RFC 8259 §8.1
+ # fixes JSON text as UTF-8 for interchange, and this method takes a source, not a response,
+ # so it has no declared charset to consult -- guarded incrementally by
+ # Dexpace::IO.max_materialized_bytes (P7-1: the whole text IS materialised; a body above the
+ # ceiling raises Dexpace::StreamError, an ::IOError, unwrapped). A raw IO answering `#read`
+ # is wrapped in a BufferedSource for the same read and the same guard; the wrapper is
+ # dropped, never closed, so the caller's IO stays open. The UTF-8 validation is P7-6: 3a's
+ # `#read_utf8` retags without validating and `::JSON.parse` accepts invalid UTF-8, returning
+ # a UTF-8-tagged String whose `#valid_encoding?` is false, so without this line a caller
+ # receives a String that claims an encoding it does not have.
+ #
+ # The parse is rescued as `::JSON::JSONError` and nothing wider: its ancestry is
+ # [ParserError, JSONError, StandardError] with IOError nowhere in it (verified fact 4), so a
+ # Dexpace::StreamError structurally cannot be caught here and SERDE-12 holds without a
+ # discipline. The witness's own DeserializationError passes through unwrapped.
+ #
+ # @param source [Dexpace::IO::BufferedSource, #read] a caller-owned stream
+ # @param witness [Object] the target: a class answering .dexpace_load, or a combinator
+ # @return [Object] the witness's decode
+ # @raise [Dexpace::Serde::DeserializationError] on invalid UTF-8, malformed JSON (chaining
+ # the parser's error), or a shape mismatch (from the witness, naming the target)
+ # @raise [Dexpace::StreamError] unwrapped, from the source
+ # @raise [Dexpace::InvalidArgumentError] on a non-witness or a non-stream source
+ def load(source, witness)
+ Dexpace::Serde.witness!(witness)
+ text = drain(source)
+ unless text.valid_encoding?
+ raise DeserializationError,
+ "the payload is not valid UTF-8 (RFC 8259 §8.1); refusing to parse it"
+ end
+
+ parsed = parse(text)
+ # SERDE-13: the root frame carries the TARGET so a top-level null names Pet, not Hash;
+ # nothing screens for nil here because SERDE-20's Tristate.of and Nullable.of want one.
+ witness.dexpace_load(parsed, DecodeContext.root(target: witness))
+ end
+
+ private
+
+ # P7-5 and 3a's precedent: a wrong KIND of argument is an argument error, never a range one.
+ def buffer!(buffer, offset)
+ unless buffer.is_a?(::String) && !buffer.frozen? && buffer.encoding == ::Encoding::BINARY
+ raise InvalidArgumentError, "buffer must be a mutable Encoding::BINARY String"
+ end
+ return if offset.is_a?(::Integer)
+
+ raise InvalidArgumentError, "offset must be an Integer, got #{offset.class}"
+ end
+
+ def drain(source)
+ return source.read_utf8 if source.respond_to?(:read_utf8)
+ if source.respond_to?(:read) || source.respond_to?(:readpartial)
+ return Dexpace::IO::BufferedSource.wrapping(source).read_utf8
+ end
+
+ raise InvalidArgumentError,
+ "source must be a Dexpace::IO::BufferedSource or an IO answering #read, " \
+ "got #{source.class}"
+ end
+
+ # ::JSON::Coder#load, which is ::JSON.parse's configured form and NOT ::JSON.load.
+ def parse(text)
+ @coder.load(text)
+ rescue ::JSON::JSONError => error
+ # Inside the rescue, so Ruby chains the parser's error as #cause (SERDE-9, SERDE-27).
+ raise DeserializationError, "malformed JSON: #{error.message}"
+ end
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs b/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs
index 51a7c86..6bad8ff 100644
--- a/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs
+++ b/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs
@@ -1,6 +1,13 @@
module Dexpace
module Serde
+ # The reference wire codec over Ruby's json: the floor it asserts (P7-7), the core constraint
+ # it registers against, and the two factories.
module JSON
+ MINIMUM_JSON_VERSION: String
+ REQUIRED_CORE: String
+
+ def self.default: () -> Codec
+ def self.build: (?Hash[Symbol, untyped]? options) -> Codec
end
end
end
diff --git a/gems/dexpace-serde-json/sig/dexpace/serde/json/codec.rbs b/gems/dexpace-serde-json/sig/dexpace/serde/json/codec.rbs
new file mode 100644
index 0000000..2e71e9e
--- /dev/null
+++ b/gems/dexpace-serde-json/sig/dexpace/serde/json/codec.rbs
@@ -0,0 +1,44 @@
+module Dexpace
+ module Serde
+ module JSON
+ # The seam's six methods over one private ::JSON::Coder. No ::JSON constant appears in this
+ # signature (NFR-11): the engine is an untyped private ivar with no reader, and rbs's stdlib
+ # json signatures (4.2.0) declare no JSON::Coder to name in any case.
+ class Codec
+ # Three private_constants with no visibility in RBS; declared so Steep can type the
+ # constructor. The privacy lives in lib/dexpace/serde/json/codec.rb.
+ module Options
+ ALLOWED: Array[Symbol]
+
+ def self?.table!: (untyped options) -> Hash[Symbol, untyped]
+ def self?.encoders!: (untyped encoders) -> Hash[Module, untyped]
+ end
+
+ DEFAULT_ENCODERS: Hash[Module, untyped]
+ MEDIA_TYPE: Dexpace::MediaType
+
+ @coder: untyped
+ @encoders: Hash[Module, untyped]
+
+ private def self.new: (Hash[Symbol, untyped]? options) -> instance
+ def initialize: (Hash[Symbol, untyped]? options) -> void
+
+ def self.build: (?Hash[Symbol, untyped]? options) -> Codec
+ def self.default: () -> Codec
+
+ def media_type: () -> Dexpace::MediaType
+ def dump_string: (untyped value) -> String
+ def dump_bytes: (untyped value) -> String
+ def dump_to: (untyped value, untyped sink) -> Integer
+ def dump_into: (untyped value, String buffer, ?offset: Integer) -> Integer
+ def load: (untyped source, untyped witness) -> untyped
+
+ private
+
+ def buffer!: (untyped buffer, untyped offset) -> void
+ def drain: (untyped source) -> String
+ def parse: (String text) -> untyped
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-serde-json/test/dexpace/serde/json/codec_load_test.rb b/gems/dexpace-serde-json/test/dexpace/serde/json/codec_load_test.rb
new file mode 100644
index 0000000..d4822fa
--- /dev/null
+++ b/gems/dexpace-serde-json/test/dexpace/serde/json/codec_load_test.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../../test_helper"
+require_relative "../../../support/close_counting_source"
+require "dexpace/serde/json"
+require "stringio"
+
+# SERDE-3's decode half, SERDE-5, SERDE-11, SERDE-12, SERDE-13, SERDE-21, SERDE-22, SERDE-23, and
+# P7-6. An extra suite beside codec_test.rb, the file's mirror, because #load is where R1 and P7-6
+# both live. Split under Metrics/ClassLength: the stream contract, then the shapes.
+class DexpaceSerdeJSONCodecLoadTest < DexpaceTestCase
+ C = Dexpace::Serde::JSON::Codec
+ S = Dexpace::Serde
+
+ # The DTO the SERDE-5 conformance clause decodes into.
+ class Pet
+ attr_reader :name
+
+ def self.dexpace_load(parsed, ctx)
+ h = ctx.object!(parsed)
+ new(ctx.string!(h["name"], key: "name"))
+ end
+
+ def initialize(name) = @name = name
+ end
+
+ # The BufferedSource every case reads from.
+ module Fixtures
+ def source(text) = Dexpace::IO::BufferedSource.of_bytes(text.b)
+ end
+
+ # SERDE-3, SERDE-5, SERDE-9, SERDE-12 and R1: the stream contract and the failure model.
+ class StreamTest < DexpaceTestCase
+ include Fixtures
+
+ # SERDE-5's conformance clause: decode a JSON object into a concrete DTO via the type-witness
+ # path and assert the result is the REAL DTO type with typed field access.
+ test "SERDE-5: decode through an explicit witness yields the real type" do
+ pet = C.default.load(source("{\"name\":\"Ré\"}"), Pet)
+
+ assert_instance_of(Pet, pet)
+ assert_equal("Ré", pet.name)
+ assert_equal(::Encoding::UTF_8, pet.name.encoding)
+ end
+
+ # SERDE-3, and message-bodies/a7afc6ee names this rule as 7a's: "a codec closes nothing".
+ # SERDE-3's own tail -- "even when the codec's own auto-close feature is enabled" -- holds under
+ # every option this adapter accepts, which is what the loop covers.
+ test "SERDE-3: load reads to EOF and closes the caller's source exactly zero times" do
+ [C.default, C.build(max_nesting: 4), C.build(allow_nan: true), C.build(script_safe: true),
+ C.build(allow_duplicate_key: true),].each do |codec|
+ tracked = CloseCountingSource.new("{\"name\":\"x\"}")
+
+ codec.load(tracked, Pet)
+
+ assert_equal(0, tracked.close_count)
+ assert_predicate(tracked, :at_eof?)
+ end
+ end
+
+ test "SERDE-3: a raw IO answering #read is read to EOF and left open too" do
+ io = StringIO.new("{\"name\":\"x\"}".b)
+
+ assert_equal("x", C.default.load(io, Pet).name)
+ refute_predicate(io, :closed?)
+ assert_predicate(io, :eof?)
+ end
+
+ test "SERDE-5: there is no witness-less overload to fall into" do
+ assert_raises(::ArgumentError) { C.default.load(source("{}")) }
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.load(source("{}"), 5) }
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.load(source("{}"), ->(p, _c) { p }) }
+ end
+
+ # SERDE-9 from the decode side, and SERDE-11 (SHOULD, satisfied by the language: every one of
+ # these is a StandardError descendant and appears in no declared signature).
+ test "SERDE-13/SERDE-9: malformed input is the DESERIALIZATION subtype, with a cause" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ C.default.load(source("{not json"), Pet)
+ end
+
+ assert_kind_of(::JSON::JSONError, error.cause)
+ assert_kind_of(::StandardError, error)
+ refute_kind_of(::JSON::JSONError, error)
+ end
+
+ test "SERDE-9: a nesting-depth failure is the deserialization subtype chaining the library's" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ C.build(max_nesting: 2).load(source("[[[1]]]"), Pet)
+ end
+
+ assert_kind_of(::JSON::NestingError, error.cause)
+ end
+
+ # SERDE-12, satisfied STRUCTURALLY by verified fact 4: JSON::JSONError's ancestry is
+ # [JSON::ParserError, JSON::JSONError, StandardError, Exception] and IOError is nowhere in it,
+ # so `rescue ::JSON::JSONError` CANNOT catch a Dexpace::StreamError. The codec writes no
+ # `rescue StandardError` and this test is what proves the narrow rescue is load-bearing.
+ test "SERDE-12: a genuine stream I/O error propagates UNWRAPPED" do
+ failing = Object.new
+ def failing.read_utf8(*) = raise Dexpace::StreamError, "connection reset"
+
+ error = assert_raises(Dexpace::StreamError) { C.default.load(failing, Pet) }
+
+ refute_kind_of(Dexpace::Serde::Error, error)
+ assert_kind_of(::IOError, error)
+ end
+
+ test "SERDE-12: a raw IO's own failure propagates unwrapped as well" do
+ broken = Object.new
+ def broken.read(*) = raise ::IOError, "closed stream"
+
+ assert_raises(::IOError) { C.default.load(broken, Pet) }
+ end
+
+ # R1 clause 3, and the observable behaviour a caller will meet: a body above IO-9's ceiling
+ # raises a StreamError (an ::IOError), which propagates past the codec's rescue for the same
+ # reason. No test allocates 64 MiB: the ceiling is 3a's and tested there; this stubs the
+ # source's refusal.
+ test "R1/P7-1: an over-ceiling body surfaces as a StreamError, not as a serde error" do
+ over_ceiling = Object.new
+ def over_ceiling.read_utf8(*)
+ raise Dexpace::StreamError, "materialisation would exceed MAX_MATERIALIZED_BYTES"
+ end
+
+ assert_raises(Dexpace::StreamError) { C.default.load(over_ceiling, Pet) }
+ end
+
+ test "the source must be a stream: neither #read_utf8 nor #read means it is refused" do
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.load("{}", Pet) }
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.load(nil, Pet) }
+ end
+ end
+
+ # SERDE-13, SERDE-20–SERDE-23 and P7-6: what the witness sees, and the UTF-8 guard.
+ class ShapeTest < DexpaceTestCase
+ include Fixtures
+
+ # SERDE-13 across "every decode overload" -- which is one method here, so one test. Its
+ # conformance clause decodes "the literal null into a non-null DTO" and asserts the message
+ # names THE TARGET TYPE, so /Pet/ is the assertion and /Hash/ is the shape that rides beside it.
+ # #load builds DecodeContext.root(target: witness) for exactly this.
+ test "SERDE-13: a wire null into a non-null target names the target type" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) { C.default.load(source("null"), Pet) }
+
+ assert_equal("expected DexpaceSerdeJSONCodecLoadTest::Pet (Hash) at /, got NilClass",
+ error.message,)
+ end
+
+ # And the repair is WITNESS-AWARE rather than a nil check in #load: SERDE-20 requires
+ # "deserialize a top-level null -> Null", so a combinator that legitimately accepts nil must
+ # still succeed. Neither of these calls ctx.object! on nil, so nothing raises and neither needs
+ # an exemption -- which is the whole reason #load does not screen for nil itself.
+ test "SERDE-20: a top-level null still decodes through Nullable and Tristate" do
+ assert_nil(C.default.load(source("null"), S::Nullable.of(Pet)))
+ assert_predicate(C.default.load(source("null"), S::Tristate.of(String)), :null?)
+ end
+
+ # SERDE-21/SERDE-22, through the REAL codec rather than through DecodeContext alone: JSON.parse
+ # performs no coercion (verified), so the strictness burden is entirely the witness's.
+ test "SERDE-21: the codec never coerces, so the witness sees the wire shape" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) do
+ C.default.load(source("{\"name\":5}"), Pet)
+ end
+
+ assert_equal("expected String at /name, got Integer", error.message)
+ end
+
+ test "SERDE-22: an integer widens into a float target through the real decode path" do
+ assert_in_delta(1.0, C.default.load(source("1"), S::List.of(Float).element))
+ assert_equal([1.0, 2.5], C.default.load(source("[1, 2.5]"), S::List.of(Float)))
+ end
+
+ test "SERDE-23: an unknown field is ignored" do
+ assert_equal("x", C.default.load(source("{\"name\":\"x\",\"new_field\":1}"), Pet).name)
+ end
+
+ # P7-6. Verified fact 7: #read_utf8 retags without validating (3a's stated contract) and
+ # ::JSON.parse accepts invalid UTF-8 and returns a UTF-8-tagged String whose #valid_encoding? is
+ # false. Without this guard a caller receives a String that claims an encoding it does not have.
+ test "P7-6: invalid UTF-8 in the payload is a deserialization failure, not a corrupt String" do
+ invalid = source("{\"name\":\"\xff\"}".b)
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) { C.default.load(invalid, Pet) }
+
+ assert_match(/UTF-8/, error.message)
+ assert_nil(error.cause, "the port's own guard, not the library's")
+ end
+
+ test "P7-6: well-formed non-ASCII survives the same path untouched, and a BOM is refused" do
+ assert_equal("héllo wörld", C.default.load(source("{\"name\":\"héllo wörld\"}"), Pet).name)
+ assert_raises(Dexpace::Serde::DeserializationError) do
+ C.default.load(source("\xEF\xBB\xBF{\"name\":\"x\"}".b), Pet)
+ end
+ end
+
+ test "an empty source is a deserialization failure chaining the parser's own end-of-input" do
+ error = assert_raises(Dexpace::Serde::DeserializationError) { C.default.load(source(""), Pet) }
+
+ assert_kind_of(::JSON::ParserError, error.cause)
+ end
+
+ test "the witness's own DeserializationError passes through unwrapped and unchained" do
+ strict = Class.new do
+ def self.dexpace_load(_parsed, ctx) = ctx.error!(expected: "Never", actual: 1)
+ end
+
+ error = assert_raises(Dexpace::Serde::DeserializationError) { C.default.load(source("1"), strict) }
+
+ assert_equal("expected Never at /, got Integer", error.message)
+ assert_nil(error.cause)
+ end
+ end
+end
diff --git a/gems/dexpace-serde-json/test/dexpace/serde/json/codec_test.rb b/gems/dexpace-serde-json/test/dexpace/serde/json/codec_test.rb
new file mode 100644
index 0000000..519aad8
--- /dev/null
+++ b/gems/dexpace-serde-json/test/dexpace/serde/json/codec_test.rb
@@ -0,0 +1,375 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../../../test_helper"
+require_relative "../../../support/close_counting_sink"
+require "dexpace/serde/json"
+
+# SEAM-20's four allocation profiles, SERDE-4's buffer contract, SERDE-9/SERDE-10's failure model,
+# SERDE-25's factory, SERDE-26's private engine and SERDE-29's sharing. Every reference to Ruby's
+# JSON is ::JSON -- phase 2's Dexpace/QualifiedCoreConstant, and this is the first code it bites.
+# Split under Metrics/ClassLength: the encode profiles, the failure model, the construction and
+# the keywords the private engine is built with.
+class DexpaceSerdeJSONCodecTest < DexpaceTestCase
+ C = Dexpace::Serde::JSON::Codec
+
+ # Codec#load runs Dexpace::Serde.witness! on its second argument, so a bare lambda is NOT a
+ # witness. A named class answering .dexpace_load is the smallest thing that is one.
+ class Identity
+ def self.dexpace_load(parsed, _ctx) = parsed
+ end
+
+ # The BufferedSource every decode case reads from.
+ module Fixtures
+ def source(text) = Dexpace::IO::BufferedSource.of_bytes(text.b)
+ end
+
+ # SEAM-20's four profiles, SERDE-3's encode half and SERDE-4's buffer contract.
+ class EncodeProfilesTest < DexpaceTestCase
+ include Fixtures
+
+ test "SERDE-1: the seam's six methods are all present, so .conforms? accepts it" do
+ assert(Dexpace::Serde.conforms?(C.default))
+ assert_empty(Dexpace::Serde.missing_methods(C.default))
+ end
+
+ test "SEAM-19/SERDE-2: it declares its own media type as a MediaType" do
+ assert_equal(Dexpace::MediaType.parse("application/json"), C.default.media_type)
+ assert_same(C.default.media_type, C.default.media_type, "one frozen constant, never a parse")
+ end
+
+ # §10.13: a String tagged Encoding::BINARY *is* Ruby's byte array, so these two differ exactly
+ # in the encoding tag -- and both ship because the tag is load-bearing at §3.1's boundary.
+ test "SEAM-20: dump_string and dump_bytes differ exactly in the encoding tag" do
+ codec = C.default
+
+ assert_equal(::Encoding::UTF_8, codec.dump_string({ "a" => "é" }).encoding)
+ assert_equal(::Encoding::BINARY, codec.dump_bytes({ "a" => "é" }).encoding)
+ assert_equal(codec.dump_string({ "a" => "é" }).b, codec.dump_bytes({ "a" => "é" }))
+ assert_equal("{\"a\":\"é\"}", codec.dump_string({ "a" => "é" }))
+ end
+
+ test "dump_string answers a fresh String each call, never a shared one" do
+ codec = C.default
+
+ refute_same(codec.dump_string([1]), codec.dump_string([1]))
+ refute_predicate(codec.dump_string([1]), :frozen?)
+ end
+
+ test "SERDE-3: dump_to writes the bytes, answers the count, and never closes the sink" do
+ sink = CloseCountingSink.new
+ written = C.default.dump_to({ "a" => "é" }, sink)
+
+ assert_equal(sink.string.bytesize, written)
+ assert_equal(C.default.dump_bytes({ "a" => "é" }), sink.string)
+ assert_equal(0, sink.close_count)
+ end
+
+ # SERDE-4's conformance clause, all four parts.
+ test "SERDE-4: encode into an oversized buffer at an offset" do
+ payload = C.default.dump_bytes({ "a" => "é" })
+ buffer = ("\0" * (payload.bytesize + 8)).b
+ written = C.default.dump_into({ "a" => "é" }, buffer, offset: 4)
+
+ assert_equal(payload.bytesize, written)
+ assert_equal(payload, buffer.byteslice(4, payload.bytesize))
+ assert_equal("\0\0\0\0".b, buffer.byteslice(0, 4), "bytes before the offset are untouched")
+ assert_equal("\0\0\0\0".b, buffer.byteslice(4 + payload.bytesize, 4), "and after it")
+ assert_equal(payload.bytesize + 8, buffer.bytesize)
+ end
+
+ # Verified fact 12 is why the explicit fit check exists: String#[]= with an in-range offset and
+ # an over-long payload silently GROWS the string rather than raising, which is the one behaviour
+ # SERDE-4 exists to forbid. The overflow is raised `cause: nil`, and that is observable only
+ # when an exception is in flight -- so the call is made from inside a rescue.
+ test "SERDE-4: a one-byte-short buffer raises a range error, NOT the serde type, unchained" do
+ payload = C.default.dump_bytes({ "a" => 1 })
+ buffer = ("\0" * (payload.bytesize - 1)).b
+
+ error = assert_raises(::IndexError) do
+ raise "in flight"
+ rescue ::RuntimeError
+ C.default.dump_into({ "a" => 1 }, buffer, offset: 0)
+ end
+
+ refute_kind_of(Dexpace::Serde::Error, error)
+ assert_nil(error.cause)
+ assert_equal(payload.bytesize - 1, buffer.bytesize, "the buffer must not have grown")
+ assert_equal("\0" * (payload.bytesize - 1), buffer, "and must be untouched")
+ end
+
+ test "SERDE-4: an out-of-range offset raises IndexError and leaves the buffer untouched" do
+ buffer = ("\0" * 4).b
+
+ assert_raises(::IndexError) { C.default.dump_into(1, buffer, offset: 9) }
+ assert_raises(::IndexError) { C.default.dump_into(1, buffer, offset: -1) }
+ assert_raises(::IndexError) { C.default.dump_into(1, buffer, offset: 4) }
+ assert_equal("\0\0\0\0".b, buffer)
+ end
+
+ test "SERDE-4: an exact fit at offset 0 and at the last possible offset both succeed" do
+ payload = C.default.dump_bytes(1)
+ exact = ("\0" * payload.bytesize).b
+
+ assert_equal(payload.bytesize, C.default.dump_into(1, exact, offset: 0))
+ assert_equal(payload, exact)
+
+ tail = ("\0" * (payload.bytesize + 3)).b
+
+ assert_equal(payload.bytesize, C.default.dump_into(1, tail, offset: 3))
+ assert_equal(payload, tail.byteslice(3, payload.bytesize))
+ end
+
+ # P7-5. Ruby's IO::Buffer is excluded by measurement, not by taste: it warns through
+ # Warning.warn at every level and phase 0's shared test case overrides Warning.warn TO RAISE,
+ # so a test constructing one fails the build -- and a requirement whose conformance clause
+ # cannot be tested is not satisfied. Also, its set_string raises ArgumentError where
+ # String#[]= raises IndexError.
+ test "P7-5: a frozen or non-BINARY buffer is refused as an argument error, not a range error" do
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.dump_into(1, " ".b.freeze, offset: 0) }
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.dump_into(1, +" ", offset: 0) }
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.dump_into(1, [], offset: 0) }
+ assert_raises(Dexpace::InvalidArgumentError) { C.default.dump_into(1, ("\0" * 4).b, offset: 1.0) }
+ end
+
+ test "the suite constructs no Ruby IO::Buffer (P7-5), asserted over its own source" do
+ own = File.expand_path(__FILE__)
+ # Ruby's IO::Buffer, bare or ::-rooted -- never Dexpace::IO::Buffer, core's own FIFO.
+ ruby_io_buffer = /(?\"" instead of raising. Two layers stop it
+ # -- core's Native walk rejects a non-native value first, and strict: true makes the generator
+ # itself raise.
+ test "SERDE-9/SERDE-10: an unserializable value raises the SERIALIZATION subtype, not json's" do
+ error = assert_raises(Dexpace::Serde::SerializationError) { C.default.dump_string(Object.new) }
+
+ refute_kind_of(::JSON::JSONError, error)
+ assert_match(/Object/, error.message)
+ assert_raises(Dexpace::Serde::SerializationError) { C.default.dump_string([Object.new]) }
+ assert_raises(Dexpace::Serde::SerializationError) { C.default.dump_bytes(:sym) }
+ end
+
+ test "SERDE-10: the write-path subtype is distinct from the read-path one under one root" do
+ write = assert_raises(Dexpace::Serde::SerializationError) { C.default.dump_string(Object.new) }
+ read = assert_raises(Dexpace::Serde::DeserializationError) { C.default.load(source("{"), Identity) }
+
+ assert_kind_of(Dexpace::Serde::Error, write)
+ assert_kind_of(Dexpace::Serde::Error, read)
+ refute_kind_of(Dexpace::Serde::DeserializationError, write)
+ refute_kind_of(Dexpace::Serde::SerializationError, read)
+ end
+
+ test "SERDE-9: a library failure is caught and chained rather than escaping the SPI" do
+ error = assert_raises(Dexpace::Serde::SerializationError) { C.default.dump_string(::Float::NAN) }
+
+ assert_kind_of(::JSON::JSONError, error.cause)
+ assert_match(/NaN/, error.message)
+ end
+
+ # `strict: true` is belt and braces: Native.of refuses every non-native value before the Coder
+ # sees it, so the option is observable only past the walk. This drives the private engine
+ # directly, past Native, and asserts the generator is strict on its own account.
+ test "the private engine is strict on its own account, past the walk" do
+ coder = C.default.instance_variable_get(:@coder)
+
+ assert_raises(::JSON::GeneratorError) { coder.dump(Object.new) }
+ assert_raises(::JSON::GeneratorError) { coder.dump(::Time.at(0)) }
+ end
+
+ test "the generator never round-trips a bare Object as its inspect string" do
+ refute_match(/#