Repository navigation
Phase 7a: serialization — the serde primitives and the JSON codec - #87
Merged
Merged
Conversation
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.
This was referenced Sep 20, 2026
This was referenced Sep 20, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #26. First PR of phase 7a's three-PR stack — the code — built off
mainatc53638b(phases 0–6) concurrently with 7b #27, 7c #28 and 8a #30, reviewed and approved there, then rebased and re-proven ontomainat5e2cb21(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'smain—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_Witnessinterface declared insidemodule Serde;#callis explicitly not a witness);DecodeContext(.build(path:, target:)public becauseModel#withneeds it,.new/.[]private;#pointeris an RFC 6901 JSON Pointer with~0/~1escaping, the root frame rendered as/in a message; an anonymous class gets no target, P7-70); the tri-state value type (Presentvalidates in#initializewith.newand.[]private —Model#withkeeps the fourth state closed on 3.2,SERDE-14); the scalar witnesses (Scalarsaprivate_constant,BOOLEANpublic,::Timedeliberately absent from core's table — the adapter's encoder table wiresTime/DateTime/DatetoInstant, whoseiso8601(6)truncation is asserted executably, P7-8); theList,Map,Nullablecombinators;NativeandOMIT(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 thenis_a?encoder lookup, P7-71);DecodingHandlerandStatusAwareHandlerover 3b'sTypedResponse(membersserde,witness,factory; the empty body screened withBufferedSource#eof?on the one source handle — nevercontent_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, inhttp/body.rb; a non-codec serde is refused by name; a CR in the media type is refused byMediaType.parse.interface _Codecedited in place atDexpace::_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,#loadover_Witness,#dump_to→Integer. No second interface.dexpace-serde-json: the gemspec'sjson >= 2.19.9line — the first third-party half of anNFR-2budget in the workspace, landed before the firstrequire "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::SeamErrornaming the active json and thegemline to add — P7-7; the stock json is below the floor on three of the four interpreters). The engine is a per-instanceJSON::Coderbuilt with keywords only —strict: true,allow_duplicate_key: falsefixed unless the caller opts in (json 2.19.9 warns on a duplicate key where 3.0 raises; the codec makes it oneDeserializationErroron 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).#loaddrains throughread_utf8, guardsvalid_encoding?(a decoded JSON String can be UTF-8-tagged and invalid, P7-6), rescues::JSON::JSONErroraround the parse alone, and accepts a raw#readIO through a droppedBufferedSource.wrappingso the caller's IO stays open (SERDE-3, P7-72).:serde_jsontarget downgradesRuby::UnknownConstantto:informationon that target alone (P7-62): rbs 4.2.0's stdlib json signatures declare noJSON::Coderand json 3.0.2 ships nosig/forrbs collectionto pick up (checked in the bundle first); never a line-level ignore, never core's strict target,@codertypeduntyped, the re-tighten condition in the comment.rbs_collection.yamlis byte-identical tomain(round 0's R0-1 reverted an unneeded comment edit).rake test:gemsruns every gem's suite in one process, so the adapter's require-timeSerde.register(:json)turnedseam_surface_test.rb:17/:22andserde_test.rb's three "starts empty" pins red; the seam-iterating pins now assert in a child process (independence_test.rb'sIO.popenshape) and the two swap pins assert the override is gone. 8a converts the same twoseam_surface_test.rblines for its own registration — the reconcile pass keeps one copy.sig/mirrorslib/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-json2 → 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'sFakeTransport(out of another gem's reach) — and asserts theContent-Typeheader absent on the composed request, because the media type travels asbody.media_typeandTRANSPORT-10makes the header the transport's (8a's socket twin is the other half, guarded on this codec until both are on one tip);FakeResponseBodyover a realResponseis the counting body (noCountingResponse—TypedResponsetype-checks its response, P7-64); theSEAM-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
mainThe 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:regeneratechanged nothing).gates:serde_boundary(7b's) stays green withserde/beside the guardedsse/andpage/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 onlymaintouched is byte-identical tomain. Thefeatcommit's message was reworded back verbatim afterrebase --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
rbs_collection.yamlcomment edit (reverted), theallow_duplicate_key: falsedefault 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-strippedfloor_test.rbthat pins each interpreter's stock json below the floor); one code change from review, the anonymous-witness message (R1-4).strict: true—JSON::Coderis 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).gates:clean_bundleon 3.3.12 and 3.4.10 with no network fetch on the second run; the duplicate-key input through#loadon 2.19.9 and 3.0.2; the fourth tri-state state on every construction path on 3.2.11 (Data#withskips#initializethere —Model#withis the mitigation, and the floor row is the one that proves it); the five registry pins in one process with the adapter loaded, versusmain's originals swapped in (exactly the five failures); the composition slice end to end; everyserde.mdexample run.Known follow-ups from the final review (not blocking a gate)
serde.md:329: block 10 usesStringIO.newwithout arequire "stringio", so it raisesNameErrorin a fresh process.CLAUDE.md:885overstates guard 21: arescue StandardErroraround the drain is what the suite runs red; one around the parse alone is the recorded equivalent mutant.floor_test.rbsince round 1).:serde_jsonrelaxation downgrades everyUnknownConstantin that target, not onlyJSON::Coder(recorded in P7-62 with the re-tighten condition).Nativerecurses 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.gates:clean_bundlefetches 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 ownsclean_bundle_checkthis wave and may close it); the roadmap's phase-10 bullet on the "unwritten"_Codeccorrected in place with a dated bracketed sentence; the knowledge-lookup skill's serde audit row widened (--section rulesalone missesSERDE-17/24/25/30);serde_seam_assertions.rbis phase 9's lift target intodexpace-conformance.