Skip to content

Phase 7a: serialization — the serde primitives and the JSON codec - #87

Merged
Wahbeh-Mohammad merged 3 commits into
mainfrom
26-phase-7a-serialization
Sep 20, 2026
Merged

Wahbeh-Mohammad merged 3 commits into
mainfrom
26-phase-7a-serialization

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Part of #26. First PR of phase 7a's three-PR stack — the code — built off main at c53638b (phases 0–6) concurrently with 7b #27, 7c #28 and 8a #30, reviewed and approved there, then rebased and re-proven onto main at 5e2cb21 (7b #81–#83 and 7c #84–#86) by a reconcile pass before this push. Its tests are the next PR up and the phase record the one after. Phase 7 is complete with this stack.

What lands

The serialization layer in core and the workspace's second real gem, dexpace-serde-json — 37 files, +2,339 / −42 on top of 7b's and 7c's main — SERDE-1–SERDE-30, all thirty ✅.

  • Dexpace::Serde's primitives, in core (lib/dexpace/serde/): the witness protocol (Serde.witness! / .witness?, WITNESS_METHOD, DUMP_METHOD, the _Witness interface declared inside module Serde; #call is explicitly not a witness); DecodeContext (.build(path:, target:) public because Model#with needs it, .new/.[] private; #pointer is an RFC 6901 JSON Pointer with ~0/~1 escaping, the root frame rendered as / in a message; an anonymous class gets no target, P7-70); the tri-state value type (Present validates in #initialize with .new and .[] private — Model#with keeps the fourth state closed on 3.2, SERDE-14); the scalar witnesses (Scalars a private_constant, BOOLEAN public, ::Time deliberately absent from core's table — the adapter's encoder table wires Time/DateTime/Date to Instant, whose iso8601(6) truncation is asserted executably, P7-8); the List, Map, Nullable combinators; Native and OMIT (the seven rules in the design's order; String and Symbol keys coerced to String, any other key class refused, a Symbol value refused; exact-class then is_a? encoder lookup, P7-71); DecodingHandler and StatusAwareHandler over 3b's TypedResponse (members serde, witness, factory; the empty body screened with BufferedSource#eof? on the one source handle — never content_length, which is −1 when unknown, and never a parser message, which is version-dependent, P7-67; an anonymous witness is named "an anonymous witness" in the two messages, round 1's R1-4).
  • Body.serialized(value, serde:) — the ninth factory beside 3b's eight, in http/body.rb; a non-codec serde is refused by name; a CR in the media type is refused by MediaType.parse.
  • interface _Codec edited in place at Dexpace::_Codec (sig/dexpace/serde.rbs — it has had a full body since Phase 2: seam foundations — the seam layer #44; the design's "never written" finding rested on a false premise and is withdrawn with the evidence, P7-61): #media_type → MediaType | String, #load over _Witness, #dump_to → Integer. No second interface.
  • dexpace-serde-json: the gemspec's json >= 2.19.9 line — the first third-party half of an NFR-2 budget in the workspace, landed before the first require "json" because phase 0's require audit permits an adapter's third-party gem only once the gemspec declares it; Dexpace::Serde::JSON.build / .default, Dexpace::Serde::JSON::Codec (.build / .default, #media_type, #dump_string / #dump_bytes / #dump_to / #dump_into, #load), MINIMUM_JSON_VERSION, REQUIRED_CORE (~> 0.0, equal to the gemspec's core requirement), and the require-time floor assertion (Dexpace::SeamError naming the active json and the gem line to add — P7-7; the stock json is below the floor on three of the four interpreters). The engine is a per-instance JSON::Coder built with keywords only — strict: true, allow_duplicate_key: false fixed unless the caller opts in (json 2.19.9 warns on a duplicate key where 3.0 raises; the codec makes it one DeserializationError on both), encoders: never forwarded (2.19.9 swallows unknown Coder options, 3.0.2 refuses them), the options as one positional Hash over a five-key allowlist (P7-65). #load drains through read_utf8, guards valid_encoding? (a decoded JSON String can be UTF-8-tagged and invalid, P7-6), rescues ::JSON::JSONError around the parse alone, and accepts a raw #read IO through a dropped BufferedSource.wrapping so the caller's IO stays open (SERDE-3, P7-72).
  • The Steepfile's :serde_json target downgrades Ruby::UnknownConstant to :information on that target alone (P7-62): rbs 4.2.0's stdlib json signatures declare no JSON::Coder and json 3.0.2 ships no sig/ for rbs collection to pick up (checked in the bundle first); never a line-level ignore, never core's strict target, @coder typed untyped, the re-tighten condition in the comment. rbs_collection.yaml is byte-identical to main (round 0's R0-1 reverted an unneeded comment edit).
  • The five in-process registry pins converted on this branch (P7-63): rake test:gems runs every gem's suite in one process, so the adapter's require-time Serde.register(:json) turned seam_surface_test.rb:17/:22 and serde_test.rb's three "starts empty" pins red; the seam-iterating pins now assert in a child process (independence_test.rb's IO.popen shape) and the two swap pins assert the override is gone. 8a converts the same two seam_surface_test.rb lines for its own registration — the reconcile pass keeps one copy.
  • sig/ mirrors lib/ one file per file in both gems; the manifests regenerated once — core +73 rows (1,137 → 1,210 on the old base; 1,257 → 1,330 on top of 7b and 7c), dexpace-serde-json 2 → 15 (+13), the other four unchanged — every row read against the object model.

Decisions taken in the open, against the plan's text

Ledger rows P7-61–P7-72 in the design's As-built addendum and the checklist's "Deviations from the plan". Beyond those above: the composition slice (the generator path through Dexpace::Operation → Pipeline.standard → TypedResponse → the codec) runs over a three-positional recording lambda, never core's FakeTransport (out of another gem's reach) — and asserts the Content-Type header absent on the composed request, because the media type travels as body.media_type and TRANSPORT-10 makes the header the transport's (8a's socket twin is the other half, guarded on this codec until both are on one tip); FakeResponseBody over a real Response is the counting body (no CountingResponse — TypedResponse type-checks its response, P7-64); the SEAM-2 "core never names the adapter" scan reads code through Ripper with comments dropped, because two core comments already name it (P7-68).

Reconciliation onto 7b's and 7c's main

The rebase met one code conflict — lib/dexpace.rb, resolved inside the feat commit as 7b's block, 7c's, then 7a's — and the core manifest auto-merged to exactly the regenerated 1,330 rows (surface:regenerate changed nothing). gates:serde_boundary (7b's) stays green with serde/ beside the guarded sse/ and page/ globs; the five converted registry pins hold with all three phase-7 layers loaded in one process. Every file only 7a touched is byte-identical to the reviewed tip; every file only main touched is byte-identical to main. The feat commit's message was reworded back verbatim after rebase --continue's #-comment cleanup dropped three body lines beginning with #media_type / #read_utf8 (same tree, author and date).

Layering

Each tip is green under every gate on its own tree — eighteen gates now, 7b's included. This branch is green on the SimpleCov floor too — 97.82% on 4.0.6 (0 failures); the tests PR takes the same tree to 99.96% with 3,380 runs / 71,339 assertions / 0 skips.

Verification

  • Independent review, three rounds by three fresh reviewers with a fix round between each: round 0 0 blocking / 2 should-fix / 3 nits; round 1 0 / 2 / 2; round 2 approve, 0 / 0 / 3 nits. Every should-fix was a test or layering gap — an out-of-bounds rbs_collection.yaml comment edit (reverted), the allow_duplicate_key: false default unpinned on json 3.0.2 (now pinned by a child-process keyword probe that also holds at json 2.19.9 unbundled), the empty-body screen unpinned, the require-time floor assertion exercised by no test (now a bundler-stripped floor_test.rb that pins each interpreter's stock json below the floor); one code change from review, the anonymous-witness message (R1-4).
  • All gates individually at this tip on 4.0.6 (seventeen on the old base, eighteen after the rebase); the matrix set on 3.2.11, and on 3.3.12 and 3.4.10 at the tests tip; honest RuboCop clean; probe clean at the docs tip — each run by the reviewers on the old base and again by the reconcile pass and the manager's pre-push checks on the rebased tips.
  • Mutations: 49 (45 caught), 59 (55 caught), 51 (49 caught) by the three reviewers on 4.0.6 and 3.2.11; the survivors are the recorded equivalents (strict: true — JSON::Coder is strict on its own account on 2.19.9 and 3.0.2, pinned at the keyword level; a widened rescue around the parse alone cannot reach the drain).
  • By experiment, both interpreters: the floor assertion against each interpreter's stock json pinned unbundled; gates:clean_bundle on 3.3.12 and 3.4.10 with no network fetch on the second run; the duplicate-key input through #load on 2.19.9 and 3.0.2; the fourth tri-state state on every construction path on 3.2.11 (Data#with skips #initialize there — Model#with is the mitigation, and the floor row is the one that proves it); the five registry pins in one process with the adapter loaded, versus main's originals swapped in (exactly the five failures); the composition slice end to end; every serde.md example run.

Known follow-ups from the final review (not blocking a gate)

  • R2-1 (nit, docs) — serde.md:329: block 10 uses StringIO.new without a require "stringio", so it raises NameError in a fresh process.
  • R2-2 (nit, docs) — CLAUDE.md:885 overstates guard 21: a rescue StandardError around the drain is what the suite runs red; one around the parse alone is the recorded equivalent mutant.
  • R2-3 (nit, docs) — the roadmap's 2026-09-20 status note says "four new suites" while listing five; the tree has six (floor_test.rb since round 1).
  • The :serde_json relaxation downgrades every UnknownConstant in that target, not only JSON::Coder (recorded in P7-62 with the re-tighten condition). Native recurses without bound on a cyclic object graph (a caller mistake, documented). A valid-UTF-8 String tagged BINARY reaches the generator as is, which warns at default verbosity on both json versions.
  • Findings routed: gates:clean_bundle fetches a declared third-party gem into the interpreter's gem dir (json 3.0.2 landed in 3.3.12's and 3.4.10's on the first run) → phase 10's inbound list, BUNDLE_PATH under a scratch dir (8a's Task 23 owns clean_bundle_check this wave and may close it); the roadmap's phase-10 bullet on the "unwritten" _Codec corrected in place with a dated bracketed sentence; the knowledge-lookup skill's serde audit row widened (--section rules alone misses SERDE-17/24/25/30); serde_seam_assertions.rb is phase 9's lift target into dexpace-conformance.

Phase 7a, Tasks 2-16 and 19's wiring. In dexpace-core, under
lib/dexpace/serde/: DecodeContext -- the `ctx` every witness receives, a
frozen Data carrying the RFC 6901 path and the decode's target name,
with the eight `!` checks and the ONE raise site that makes
SERDE-13/21/22 properties of one method; witness.rb reopening
Dexpace::Serde for WITNESS_METHOD, DUMP_METHOD, .witness? and .witness!
(a respond_to? on .dexpace_load, never on #call); Native and OMIT, the
encode walk that drops an Absent key, writes nil for one in an Array or
at the top level, and raises SerializationError naming the class where
::JSON.generate would stringify (P7-9); the private scalar table and the
named BOOLEAN witness; Tristate with ABSENT, NULL, Present (validating
in #initialize, .new and .[] private, Model#with keeping the fourth
state closed on 3.2) and the private Combinator behind .of with its two
entry points; List, Map and Nullable, frozen Datas by value from a
concrete element witness, failing at construction (SERDE-8); Instant,
the ISO-8601 witness with P7-8's microsecond domain; DecodingHandler
(SERDE-27: the body's own #source, one ensure-close, BufferedSource#eof?
for an empty body) and StatusAwareHandler (SERDE-28: 2xx delegates,
4xx/5xx raises the factory's error over Recovery.buffer_error_body's
copy with no second close, anything else closes and raises leading with
the code and the raw ETag/Location). Body.serialized is the ninth
factory (SERDE-2). Phase 2's interface _Codec is edited in place --
#media_type a MediaType or a String, #load over the new _Witness,
#dump_to an Integer -- never written twice.

In dexpace-serde-json: the gemspec's json >= 2.19.9 line, the only place
that floor is stated and the first NFR-2 third-party half spent; the
entry file asserting MINIMUM_JSON_VERSION at require time (P7-7),
registering under :json with REQUIRED_CORE, and the .default / .build
factories; and Codec, the six seam methods over one private
::JSON::Coder per instance (P7-4) built with keywords only, strict: true
and allow_duplicate_key: false fixed, encoders: never forwarded, options
as one positional Hash over a five-key allowlist, #dump_into's explicit
fit check raising IndexError cause: nil, #load draining through
#read_utf8 under the materialisation ceiling (P7-1), validating UTF-8
(P7-6) and rescuing ::JSON::JSONError around the parse alone (SERDE-12
structurally).

Every new file has a sig/ mirror; the Steepfile's :serde_json target
alone downgrades Ruby::UnknownConstant to :information for the one
JSON::Coder rbs 4.2.0 does not declare (json 3.0.2 ships no sig/). The
surface manifests are regenerated once, 86 rows. Five core pins the
require-time registration invalidates change here:
seam_surface_test.rb's two seam pins and serde_test.rb's "starts empty"
pin assert in a child process that requires dexpace alone, and its two
swap pins assert the override is gone. The adapter's smoke test
snapshots after `require "dexpace"` and pins the gem's four constants.
Review round 0, R0-1. The feat commit rewrote three lines of the
file's header comment to correct its stale "json arrives with the
codec in phase 7" sentence. The file is a shared one this phase's brief
lists out of bounds -- phase 8a adds the first row to it -- and no gate
reads the comment, so the correction rode a scope breach for nothing.
Restore the file to main's content; the stale sentence is routed by
date and content to phase 10's inbound list on the docs branch, for
whichever lane adds the first row to close. The Steepfile's :serde_json
relaxation, which is what settles the JSON::Coder reference, is
unchanged.
Review round 1, R1-4. DecodeContext.root gives an anonymous class no
target (P7-70), so that `#error!` keeps its plain form and no
`#<Class:0x...>` reaches a message -- but DecodingHandler#missing_body
and StatusAwareHandler#unhandled_message interpolated that nil
directly, and a 204 through a `Class.new` witness read "no body to
decode into : the response carried none". Each handler now derives the
name through one private #target_name that falls back to the literal
"an anonymous witness", declared in the two sig/ mirrors. A named
witness's messages are byte-for-byte what they were; P7-70's rule on
the context is unchanged.
@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* labels Sep 20, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 4fa99d8 into main Sep 20, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 26-phase-7a-serialization branch September 20, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant