Repository navigation
Phase 3b: body lifecycle — documentation and phase record - #52
Conversation
Phase 3b (issue #12). One public module, Dexpace::Body, supplies HTTP-36's whole vocabulary over one hook, #write_to(sink): BODY-1's false default, BODY-35's -1 sentinel, P3-23's read side (#source raising by name, #close a no-op), BODY-3's #to_replayable, §10.2's #each derived from #write_to (P3-21), HTTP-46's identity default, the eight factories HTTP-38 asks for in one place, BODY-32's cap clamp, BODY-30/HTTP-52's .buffer_bounded at MAX_BUFFERED_ERROR_BODY_BYTES draining through #source and closing the original in an ensure, the two private exact-length copy routines (HTTP-39/BODY-10, BODY-13, BODY-25; P3-20) and the two latches BODY-6, BODY-7 and BODY-9 share. The seven request-body variants, flat under Dexpace:: and filed under lib/dexpace/http/body/ (P3-14): BytesBody and the eager Body.string; BufferBody, materialize-once's product, reading through a fresh view so BODY-30's copy stays repeatable; StreamBody with BODY-9's mark/reset given a Ruby subject -- `pos` then `seek(pos)`, rewinding to the construction position, never 0 (P3-16) -- and close: true forcing single-use because BODY-8's own sentence says so; ChunkedBody, unconditionally single-use with no replayable: keyword (P3-19); FormBody over the +-for-space encoder that now sits beside the RFC 3986 one in PercentEncoding; FileBody with six fail-fast clauses, ::IO.copy_stream's window and deliberately no #to_path (P3-17); MultipartBody with one framing routine for the bytes and the length (count_only:, P3-29), RFC 2046's boundary grammar, HTTP-51's quote escape and CR/LF rejection, a bare-token subtype, and HTTP-3's Builder. The response side: ResponseBody, HTTP-41/BODY-14's single-use handle with Closeable's latch, a block form and BODY-33's preview; RequestLoggingBody, a fresh TeeSink per write (BODY-17..BODY-21, BODY-37); ResponseLoggingBody, the drain-once wrapper in two regimes behind one close-once guard (BODY-22..BODY-29), whose over-cap tail is BufferedSource.wrapping-owned so its close reaches the wrapper's latch (R10), with Dexpace.close_quietly's first call site. Every sig/ mirror declares every method for the strict core Steep target.
Response gains HTTP-43's #close -- a pure forward, because a Data instance holds no latch and the requirement delegates idempotence to the body's -- HTTP-42's #body_string, the SDK's one decode boundary in three load-bearing steps (resolve the charset through MediaType#charset, RETAG the BINARY bytes through 3a's #read_string, transcode with the target NAMED, because a target-less #encode follows the host's Encoding.default_internal and the retag-less recipe design §3.1 states replaces every non-ASCII byte), and #body_bytes, which decodes nothing; both close the body in an ensure (BODY-16). TypedResponse is HTTP-44's lazy wrapper: five raw accessors forwarded without a lock, #value memoised through an explicit state machine so a nil or false success runs the handler once and a failure is re-raised as the same object, HTTP-45's serialization a mutex across the state flip only, and a handler raising outside StandardError still settling the state; the handler is any #call(response), the RBS interface _ResponseHandler, which phase 7 supplies into (R7). sig/ narrows Request#body and Response#body from untyped to Dexpace::Body? on the reader, .build, .new and #initialize (P3-15) -- the narrowing phase 1 postponed to phase 3, free because no release tag exists to diff against. The entry file requires the twelve files in dependency order.
The runtime surface manifest of dexpace-core grows from 366 to 515 lines through `rake surface:regenerate`, once, and every added row was read against the plan's list: fourteen constants (R6's twelve plus MultipartBody::Part and ::Builder), MAX_BUFFERED_ERROR_BODY_BYTES, FormBody::MEDIA_TYPE, the four framing constants, Response's three readers with .resolve_charset, the two form-encoder functions, ResponseBody.new's block-form override, and Body's equality default; nothing removed and no private_constant present. The core smoke suite's constant list gains the body layer and pre-requires securerandom beside uri and strscan, since its top-level snapshot would otherwise attribute ::SecureRandom to the entry file; phase 2's seam_surface_test pin on core's non-relative requires names securerandom beside strscan and uri, the one require phase 3b adds, on the allowlist. Both are files a gate reads at run time, so they travel with the code. tools/measure_view_retention.rb is Task 13's measurement on the BODY-23 drain: it prints numbers, asserts nothing, needs no bundled gem, and its numbers are in the checklist beside the plan's decision 5.
Review round 0 of phase 3b found four defects in the body layer and two nits, none a red gate. This is the code half of the repair. R0-1: Response#body_string, #body_bytes and Body.buffer_bounded took the handle #source returns and never closed it. For a ResponseBody that is the same stream close the body's own performs, but for a BufferBody and a fits-cap ResponseLoggingBody the handle is a fresh #peek view of a buffer that outlives the call, and neither body's #close reaches it, so each read left one registered view behind -- the Array#delete growth Task 13 measured, against the design's own "every view core takes, core closes". The two readers now share a private #read_through that closes the handle in its own ensure and then the body in the method's; copy_bounded closes the source it took the same way, guarded with respond_to? as the bodies' #release methods are, because Dexpace::IO::_Source declares no #close. The over-cap composite's close is the Tail's, which deregisters its prefix view too. R0-3: the over-cap Tail forwarded the live delegate as BufferedSource#read(count), IO-16's bulk read, which fills until count bytes or EOF, so on an open connection the composite delivered the rest of the body in .wrapping's 64 KiB segments or at EOF where the delegate alone returns what has arrived. It now forwards through #read_into, IO-1's one-fill primitive -- the only member the regime probe asks of the delegate's source (plan decision 9), so the delegate's source contract stays _Source's single method -- with a zero return raising BODY-25's violation through 3a's helper. The forwarding sits in a private #read_tail beside #take_pending for Metrics/AbcSize. R0-4: MultipartBody#== and #hash ignored the subtype, so two bodies framing the same parts as multipart/form-data and multipart/mixed compared equal under HTTP-46 while their media types differed. Both now fold the subtype in. R0-5: ResponseBody admitted a bare #read_into source and then used #peek, #each, #read and #read_string on it. The construction check now asks for #read_into and #peek, the narrowest member only a BufferedSource-shaped reader answers, with a message naming both. R0-6: the entry file's twelve require_relatives follow the plan's Task 14 Step 1 order again (file_body after buffer_body); both orders were dependency-safe. sig/ declares the two private methods for the strict core target. The runtime surface manifest is unchanged: nothing public was added.
ResponseLoggingBody took @delegate.source three times -- for the prefix fill, for the regime probe and for the over-cap tail -- which is correct only over BODY-14's same-handle ResponseBody. The wrapper's stated delegate contract is #source, #content_length, #media_type and #close, and BufferBody, inside that contract, answers #source with a fresh non-consuming #peek view per call by design (P3-23). Over it the over-cap composite replayed the prefix twice and then the whole body (15 bytes for a 10-byte body, also through Response#body_bytes), and the fits-cap regime left the fill view registered in the delegate's buffer against the design's "every view core takes, core closes" (review round 1, R1-1). The drain now asks once and holds the handle in @upstream; fill_prefix, capture_complete? and the Tail read from that one handle, and #release closes it ahead of the delegate, the delegate's close in an ensure so BODY-15's transport release is attempted whatever the handle's close did. One respond_to?(:close) guard covers both handles that cannot be closed -- a Dexpace::IO::_Source declares no #close, and before a drain has asked there is no handle at all. The RBS mirror gains the ivar and the two narrowed private signatures.
Twelve suites mirroring the twelve lib files one for one, 302 runs, each opening with the IDs it exercises and nesting a class per behaviour group for the 100-line cap: body_test (the contract's defaults, #each derived from #write_to, the two copy routines, the factory table, .buffer_bounded driven over a ResponseBody, a ResponseLoggingBody in both regimes and a BufferBody plus two #source-answering doubles, BODY-31's status-blind guarantee pinned mechanically), the ten variant suites (BODY-9's probe on a real IO.pipe raising a real Errno::ESPIPE, the two writes made to overlap through a parking sink with the seek delta counted, BODY-11's six clauses one test each with /proc/self/fd counted, §7.1's residue asserted as a fact, HTTP-51's declared-length property, BODY-27's two close paths in either order, BODY-28's captured buffer surviving the close and the tail raising ClosedError after it, the fiber proof that terminates) and typed_response_test (the @value ||= bug by counting invocations, the raw accessors answering while another thread holds the parse lock, joined with a timeout). Three phase-1 suites gain sections: response_test's five groups over HTTP-42's three steps with the hostile Encoding.default_internal helper and the three-body surface, request_test's HTTP-46 body half, percent_encoding_test's FormTest asserting the two encoders differ on exactly space, ~ and *. FakeBody answers #write_to and #source; FakeResponseBody is the delegate whose #close raises, which BODY-27 and BODY-28 need and no StringIO will do. A raw #write recorder in chunked_body_test is what makes #emit_exactly's own retag observable, the mutation the adversarial review found missed.
SimpleCov at the tests tip reported 2957 of 2960 lines, and two of the three misses were phase 3b's: the block-shaped sink keeping a chunk that is already frozen BINARY rather than copying it -- BytesBody#each is the path, and the test asserts the yielded chunk is the body's own frozen bytes -- and clamp_cap refusing a cap that is neither an Integer nor Float::INFINITY, which ResponseBody#preview now proves with a Float and a String. The third miss is phase 2's registry claim swap, the race-only branch every phase since has recorded.
The tests half of review round 0's repair, sixteen rows across five suites, every one seen red against the corresponding single-edit mutant on 4.0.6 and restored. R0-2 (BODY-32): "silently clamp the cap down to the ceiling" had no symptom on a ten-byte body, so deleting the clamp survived the whole suite. The witness is the COUNT the first read asks the source for: buffer_bounded over the FakeSource-backed double, and #preview over a readpartial-recording stream under .wrapping, both assert exactly MAX_MATERIALIZED_BYTES for ceiling+1, ceiling*2 and Float::INFINITY. The deletion now fails one test in each suite; the .max spelling five. R0-1: twenty body_string/body_bytes calls over a BufferBody and over a fits-cap ResponseLoggingBody leave zero views registered in the buffer, one body_bytes over an over-cap wrapper leaves the Tail's prefix view deregistered, and twenty buffer_bounded copies over each leave zero too; a handle whose close raises still leaves the body closed, which pins the two ensures against one flat ensure. R0-3: a real IO.pipe with its writer held open -- ten bytes written, two captured, one probed -- and a readpartial(1000) on the tail from a thread joined with a five-second timeout returns the seven bytes that are there; the ensure closes the writer so a hang is a failure, never a stuck run. A read_into-only source proves the tail asks the delegate for nothing beyond IO-1's primitive, and a zero read on the tail is BODY-25's violation naming IO-17. R0-4: two multipart bodies over the same parts and boundary with subtypes form-data and mixed are not ==, not eql?, and hash apart. R0-5: a bare _Source (FakeSource) is refused with a message naming #peek; a view of a BufferedSource is accepted and previews.
Every ResponseLoggingBody row drove a ResponseBody, whose #source is BODY-14's same handle every call, so a wrapper that asked its delegate three times looked right. These rows drive the other body the contract admits -- a BufferBody, whose #source is a fresh #peek view per call (P3-23) -- and a fresh-view double that counts the asks (the existing CountingDelegate memoises its source and cannot see a second one): the over-cap composite delivers the body exactly once, the fits-cap fill's view and the over-cap composite's handle are deregistered by the wrapper's close, #source is asked once in either regime and never on an undrained close, the handle is closed ahead of the delegate and the delegate is still closed when the handle's close raises, and a raising handle close after a full capture is not a drain error (review round 1, R1-1). response_test.rb pins the same composite through Response#body_bytes with both registries empty afterwards.
The checklist at docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle-checklist.md, written from what was built: fifty-one rows -- the forty-nine own IDs and HTTP-46's and HTTP-3's cross-reference rows -- 46 implemented, 3 in part with the remainder deferred to a named owner, 1 deferred (BODY-36), nothing not built; the guards run red with their messages and the fifty-five-mutation battery; the audit groups; sixteen departures from the plan's text; the findings routed; the postponed work re-read. Task 13's measurement is recorded there beside the plan's decision-5 table, re-run on the merged tree rather than copied. The 3b design's ledger gains an "As built, 2026-09-16" addendum. The roadmap gains the phase-3b status note and a thirty-seventh inbound bullet for phase 10: MultipartBody refuses a non-ASCII part name or filename because its part-header sweep is the outbound header grammar. docs/sdk-documentation/body.md is the as-built page for the layer, every example run on 4.0.6 and 3.2.11 with identical output. CLAUDE.md's built-phases paragraph, gem and phase-directory sentences and three constraints-that-bite lines are rewritten from the tree (sixty-three lib files, five checklists); README.md, docs/README.md, the core README and architecture.md point at body.md. docs/first-release.md needed no line changed. The probe reports no drift and the knowledge structure verifies.
The documentation half of review round 0's repair. Nothing here is a new claim about the code: every sentence restates what the code and tests branches now do, with the numbers re-derived from the runs. The checklist: BODY-14, BODY-16, BODY-24, BODY-25, BODY-30, BODY-32, HTTP-41 and HTTP-46 say what changed and where it is proven; the "What was built" paragraph carries the new counts (1,145 runs / 6,044 assertions, 2,973 / 2,974 lines, 316 body-suite runs, the code tip at 84.33% on 4.0.6 and 84.54% on 3.2.11) and the entry file's require order; "Guards run red" now says honestly that fifty-four of the fifty-five first-battery mutants were caught by a test and the clamp deletion was not, and adds the twelve-row second table with the four neighbouring re-runs; "Deviations from the plan" gains items 17-21, one per fence the review found wanting, with the review's readpartial spelling refused and why. The design's As-built addendum gains a "Review round 0" paragraph: rule 4 (every view core takes, core closes) applied to the two readers and the bounded copy; R10's composite forwarding through read_into so the delegate's source contract is _Source on the probe and the tail alike; HTTP-46 over the subtype; the construction check as the sig's type. docs/sdk-documentation/body.md: the readers close the handle first, the over-cap tail delivers a live connection as bytes arrive, multipart bodies compare over boundary, subtype and parts, and the ResponseBody constructor asks for the reader's vocabulary. The roadmap's 2026-09-16 note carries the new counts and one sentence on the round.
The checklist's BODY-23, BODY-24 and BODY-27 rows now state the one-handle rule -- ResponseLoggingBody asks its delegate for #source once and its release closes that handle ahead of the delegate -- with the BufferBody-delegate shape that showed the three asks, and cite the DelegateHandleTest group; deviation 22 carries the departure from the plan's Task 11 fence, and a third battery table the eight mutants run red on 4.0.6 and 3.2.11 plus the six neighbours re-run. The row arithmetic reads 46 / 4 in part / 1 as the status cells have it, here and in the roadmap's 3b note (R1-2). Counts follow the tree: 1,153 runs / 6,076 assertions, 2,977 / 2,978 lines, 323 body-suite runs, the code tip at 84.21% on 4.0.6 and 84.39% on 3.2.11. The design's ledger gains a "Review round 1" paragraph under the as-built addendum, and body.md's three-bodies paragraph says a wrapper takes its delegate's handle once.
Review record for the phase 3b stack (#50 → #51 → #52)Three independent reviews, each by a fresh agent with no memory of the previous one, each re-running the gates itself on every tip and both interpreters; two fix rounds between them. Every finding got a stable id, a severity, a file:line and the command output that was its evidence.
No finding was ever blocking; nothing was skipped or disputed. Every should-fix came from the plan's own fences, not from the implementer, and all five were in one family — a handle or view taken and not closed, or a read that blocked where a one-fill read was owed. Round 0 → fixed in round 1
Round 1 → fixed in round 2
Round 2 (final) — open, carried into the PR bodies
What the final reviewer verified, in its own runs
Run: workflow |
Closes #12. Third of three phase 3b PRs — the documentation and the phase record for #50 and #51. Targets #51's branch.
What lands
9 files, +1,160 / −36, all Markdown.
docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle-checklist.md, written from the build: 51 rows — the 49 own IDs (BODY-1–BODY-37,HTTP-36–HTTP-45,HTTP-51,HTTP-52: 46 ✅, 4 ✅ in part with the remainder owned —BODY-12's clause 2 withTRANSPORT-28,BODY-34's source and predicate with phase 5a Task 13 / 5b Tasks 14–15,BODY-30's andHTTP-52's response-side clause — and 1 ⏳,BODY-36) plus the two cross-reference rows the design names,HTTP-46(the body half of by-value equality, now with a subject) andHTTP-3(the multipart body its builder list names). Plus what was built, the guards run red — the fifty-five-mutation battery with its messages, and the rows each review round added — the audit groups run, twenty-two deviations from the plan's text (none lowers a gate), the findings routed (non-ASCII multipart names → phase 10's inbound list; theBody.stringstdlib leak → phase 1 Task 1; 3a's#clear_tapand one-byte-per-read findings recorded as closed by 3a as built), Task 13's view-retention measurement re-run on the merged tree, and the postponed work re-read (nothing new postponed).#clear_tap, R10's cost paragraph now that 3a's fill fix landed, and the three review rounds' repairs.docs/sdk-design-ruby/§3.1, §5.1 and §10 are frozen; their addenda and the consolidation are a human's, stated in the roadmap note as 3a did.docs/sdk-documentation/body.md— new as-built page for the body layer (fourteen fences, sixty annotations, every one run verbatim on 4.0.6 and 3.2.11 by the reviewer);architecture.md, the core README,README.mdanddocs/README.mdupdated to point at it.CLAUDE.mdcount sentences re-derived from the tree (sixty-three phase-1/2/3a/3b files besideversion.rb, thesig/andtest/mirrors, five checklists written so far); the roadmap gains the phase 3b status note, corrected in round 1 to the row arithmetic the table's cells actually have.Verification
bundle exec rakeon Ruby 4.0.6 at this tip: exit 0, all seventeen gates green (1,153 / 6,076, 99.96%, YARD 0 undocumented).ruby .claude/skills/housekeeping/probe.rb: no drift, all eight checks;verify_knowledge_structure.rbOK; the housekeeping suite (108 runs) and the knowledge suite (92 runs) green.docs/product-spec/,docs/sdk-design-ruby/,docs/knowledge/and phase 3a's two documents.CLAUDE.mdstate against the checklist and the tree, counted the 51 rows from the table's cells, and ran everybody.mdfence by hand on both interpreters.Known follow-up from the final review (not blocking)
Body.streamover an already-closed stream leaks a raw::IOError("closed stream") from theBODY-9probe'sio.pos(stream_body.rb:90, whose rescue is::SystemCallErroronly). No requirement text is violated — the failure is loud and at construction — and it is the same argument-boundary class the checklist's "Findings routed" already sends to phase 1's plan, Task 1 forBody.string; the bullet should name the probe's::IOErrorbeside it so phase 1's sweep covers both.