Repository navigation
Phase 3a: I/O contracts — documentation and phase record - #49
Merged
Merged
Conversation
Dexpace::IO shadows ::IO for every file inside `module Dexpace` -- and core defines it, so the hazard is live from the first require. Inside that namespace `x.is_a?(IO)` and `IO === x` are silently false for a real ::IO on 3.2.11, 3.4.10 and 4.0.6 (design P3-7). SHADOWED gains exactly IO; File, StringIO and Tempfile are deliberately absent, under the standing rule that a constant joins the list in the same change that creates its shadow. WATCHED becomes the one-segment Dexpace, so the rule is "inside module Dexpace, anywhere", over every gem's lib/ (.rubocop.yml's Include: widens from two core namespaces to gems/*/lib/**/*.rb). A definition-site guard skips the constant a `module`/`class` node declares, because `module Dexpace; module IO` is itself a bare IO inside module Dexpace and the cop would otherwise reject the file that creates the hazard it exists for. The cop's own suite gains a second nested class with eight rejected and ten accepted sources, and phase 2's accepted row -- a bare Thread inside Dexpace::Transport -- moves to rejected, which the one-segment watch implies: a tightening, never a narrowing.
Dexpace::StreamError < ::IOError, with the two BODY-13 message helpers short_transfer and zero_read, and Dexpace::EndOfStreamError < ::EOFError (IO-4, IO-9, IO-11, IO-12, IO-15, IO-16, IO-17, IO-28; P3-3). The superclass is load-bearing: IO.copy_stream -- what Net::HTTP#body_stream= calls -- terminates only on an EOFError subclass from a duck-typed #readpartial. Dexpace::IOError and Dexpace::EOFError are never defined. Dexpace::Closeable#closed? reads the latch under the close mutex (IO-38, P3-6): the first requirement that reads the flag from a second thread, and design §3.1 declines to rely on the GVL. Dexpace::IO with MAX_MATERIALIZED_BYTES (IO-9, §10.18) and the RBS interfaces _Source, _Sink and _Chunked -- the last being design §10.2's canonical body representation as a type. TypedReads supplies the read vocabulary over one private #fill hook: #read_into, IO-1's tail-append primitive, deliberately not #read (P3-1), because #read, #readpartial, #getbyte and #each keep Ruby's semantics and are IO-16's bridge (P3-2, P3-13); the typed reads under the one materialisation guard (P3-4); the hand-written line machine (IO-14); #peek and #slice, views that pin the parent's cursor and never make it retain (P3-5), with invalidation cascading through a view's own views on close. TypedWrites supplies the write vocabulary over #deliver: #write_from, the ::IO-compatible #write, #write_all with IO-17's unconditional zero-read rule, the charset writes, #emit and #flush (P3-9). BufferedSource (three factories, one ownership rule read from IO-6 and design §10.12, never SEAM-3; P3-11, P3-12), Buffer (IO-7..IO-10, exempt from use-after-close on its own surface and not for its views, P3-10), BufferedSink and TeeSink (IO-25..IO-29, without #clear_tap: 3b builds a fresh tee per write). lib/dexpace.rb requires the nine files in dependency order; every sig/ mirror declares every method for the strict core Steep target.
…fest tools/rbs_surface.rb's stdlib allowlist gains EOFError and IOError -- the superclasses IO-16 and IO-4/IO-17/IO-42 give the two stream failure types -- and Encoding, which IO-13's charset reads take: core Ruby names phase 0's list never met, the false positive phase 1 fixed for Data, ArgumentError and StringScanner, with a fixture the gate test pins. The runtime surface manifest of dexpace-core grows from 318 to 367 lines, exactly the public constants and methods the nine files define; no private_constant appears, BufferedSource.__dexpace_view does under its underscore name. The core smoke suite's constant list gains the streaming layer and pins that Dexpace::IOError and Dexpace::EOFError are never defined.
Through #peek and #slice, #fill_from_parent asked the parent to buffer `behind + want` bytes, so a view's #read_into, #readpartial and #each blocked until the whole requested count had arrived: a peek over a pipe holding 10 bytes with the writer still open hung on read_into(count: 100) where the root returned the 10 at once. The design's R2 fixes the primitive at one fill and R4 says the bridge claim holds for views, so the parent-side entry point is now #dexpace_fill_beyond(behind, want), which fills until one byte past `behind` is buffered or the upstream is done, asking for the view's own count each time, and the view takes what is there. #read(n) on a view still blocks to n through the view's own #ensure_buffered loop, which is Ruby's contract (review round 0, R0-1). BufferedSource.__dexpace_view becomes a private class method with a private RBS declaration, reached from TypedReads#build_view through #send as Headers#== reaches #values, so the underscore name leaves the surface manifest before the first tag locks it (R0-3). The two RBS declarations over 100 columns are wrapped in the sig's own multi-line shape (R0-5).
Ten suites mirroring the nine lib/ files plus the one change to closeable.rb, every test file opening with the IDs it exercises and nesting one class per behaviour group for the 100-line cap. The three fakes under test/support/ -- FakeChunked (an #each-shaped body that yields frozen literals and an empty chunk, and records whether its ensure ran), FakeSource (#read_into only, scriptable to the zero read no real stream produces) and FakeSink (#write only, scriptable to a short count and to raise partway) -- are required by the suites that use them. The guards run red first: P3-6's fiber-held-mutex assertion against phase 2's unsynchronised #closed? (ThreadError expected but nothing was raised, on 3.2.11, 3.4.10 and 4.0.6); the fill(count) amendment's count guards against the pre-amendment fill(1) (Expected: 4096 Actual: 1); the Tasks 5-9 ladder against the finished TypedReads suite; the transitive view-invalidation test against the plan's single-flag protocol; and the IO-38 cross-thread close, sequenced through Thread::Queues. The Dexpace::IO shadow is probed with string evals, the consumer case included, because the block form of module_eval resolves a constant in the block's own lexical scope on every interpreter in the matrix. test/gates/rbs_surface_test.rb pins EOFError, IOError and Encoding as admitted by the NFR-11 scan.
Nine tests. typed_reads_test.rb gains a ViewFills group over the scripted parent, whose #fills counter is the assertion: #read_into, #readpartial and #each on a peek fill the parent once and return what arrived, a slice past the buffered bytes fills until its first byte is reachable and no further, and #read(n) and #read_exactly on a view still fill to the count, which is Ruby's contract. buffered_source_test gains a Views group over a live IO.pipe with the writer left open -- a watchdog closes it after two seconds so the pre-fix shape fails on `writer.closed?` instead of hanging -- plus the count a view passes to the upstream, offset included, and the view constructor's absence from the public singleton methods (review round 0, R0-1 and R0-3). Run red against the pre-fix `ensure_buffered(behind + want)`: four ViewFills failures and the pipe test's closed writer. io_test.rb's two string-eval shadow probes each created an IO.pipe inside the eval and never closed it (R0-4); each now evals a lambda and hands it the pipe end the test owns and closes in its ensure.
…nters The checklist, docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md, written from the build: forty-two rows, 34 implemented and 8 not built (the retired provider apparatus, design §10.1), the guards run red with what each said, the Tasks 5-9 ladder, the audit groups re-checked, seventeen departures from the plan's text and the findings routed. The design's ledger gains P3-13 and an "As built" addendum: #clear_tap dropped, view invalidation cascading, the nil window, the untyped duck-checked parameters, the NFR-11 allowlist, and the string-eval shadow probe. docs/sdk-documentation/io.md is the as-built page, every example run against the code, opening with the Dexpace::IO shadow a consumer that includes Dexpace meets; architecture.md, quality-gates.md, the core README, README.md and docs/README.md point at it, and docs/first-release.md's include-Dexpace blocker narrows to its release-decision half. The roadmap gains the phase 3a status note, and CLAUDE.md's built-phases paragraph, claim sentences and the constraints-that-bite line on the seventh cop are rewritten from the tree. The consolidation of P3-1..P3-13 into design §10 is a human's.
The views example on docs/sdk-documentation/io.md claimed the final slice.read raised Dexpace::ClosedError; as built it returned "", because the slice had already pulled its whole window and P3-5's "behind" failure needs a byte still unread (R0-2). The example now takes a three-byte slice and reads one byte before the parent passes it, and was re-run on 4.0.6 and 3.2.11; the section also states the one-fill rule a view now shares with the root. The checklist gains deviations 18 (a view's fill is one fill past its position, never a loop to the count) and 19 (the two wrapped .rbs lines), rewrites deviation 7 for the now-private view constructor, extends deviation 14 with the lambda probes, adds the round-0 guard-red table and the routed findings, renames #dexpace_ensure_buffered in the IO-22 row and deviation 3, cites the new suites from IO-1, IO-16 and IO-19, and carries the round-1 tip's numbers: 783 runs / 4,669 assertions, 2,222 / 2,223 lines, 261 documented methods, a 366-line manifest, 77.91% and 78.74% on the code tip. The design's As-built addendum corrects its P3-8 note and gains the view-fill note; the roadmap's status note follows.
Contributor
Author
Review record for the phase 3a stack (#47 → #48 → #49)Two 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; one fix round 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. Round 0 → fixed in round 1
Round 1 (final) — open, carried into the PR bodies
What the final reviewer verified, in its own runs
Run: workflow |
This was referenced Sep 15, 2026
Wahbeh-Mohammad
changed the base branch from
11-phase-3a-io-contracts-tests
to
main
September 15, 2026 20:42
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.
Closes #11. Third of three phase 3a PRs — the documentation and the phase record for #47 and #48. Targets #48's branch.
What lands
11 files, +856 / −50, all Markdown.
docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md, written from the build: 42 rows (IO-1–IO-42) — 34 ✅ and 8 🚫 (IO-30–IO-36andIO-39, the byte-stream provider apparatus retired by design §10.1;IO-30's behavioural clause survives as a property ofBufferedSource.of_bytes), no ⏳ and no N/A. Plus what was built, the guards run red with their messages and the Tasks 5–9 ladder, the audit groups run, nineteen deviations from the plan's text (none lowers a gate:#clear_tapdropped, the cop row flipped, cascading invalidation, theNFR-11allowlist with its fixture and gate test, the private view constructor, the one-fill view contract, the string-eval shadow probe, …), the findings routed (the view-retention cost → 3b's plan Task 13; the plan's RuboCop-baseline finding, which does not reproduce;#read_line_utf8's bound → 7b's plan Task 12; theinclude Dexpaceshadow →docs/first-release.md), and the postponed work re-read (IO-38on a GVL-free interpreter stays event-gated under the post-release triggers; nothing further postponed).#read/#readpartialleaveoutbufBINARY) carried in from the plan, and As-built notes on P3-3, P3-5, P3-7 and P3-8.docs/sdk-design-ruby/§10 is frozen; its consolidation is a human's, stated in the roadmap note.docs/sdk-documentation/io.md— new as-built page for the byte-streaming layer, every example run verbatim on 4.0.6 and 3.2.11 (the views example was corrected in review round 0 so its last line raisesClosedErroras stated);architecture.md,quality-gates.md(the seventh cop'sIOand one-segment watch), the core README,README.md,docs/README.mdupdated;docs/first-release.md'sinclude Dexpaceblocker narrowed to its release-decision half now the documentation half is written.CLAUDE.mdcount sentences re-derived from the tree (fifty-one phase-1/2/3a files besideversion.rb, thesig/andtest/mirrors, seven cops, a 366-line manifest); the roadmap gains the phase 3a status note.Verification
bundle exec rakeon Ruby 4.0.6 at this tip: exit 0, all seventeen gates green (783 / 4,669, 99.95%, 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/anddocs/knowledge/.CLAUDE.mdstate against the checklist and the tree, and ran everyio.mdexample by hand on both interpreters.Known follow-ups from the final review (not blocking)
…-design.md:1196) still quotes the pre-review shadow probeclass_eval("::IO.pipe.first.is_a?(IO)", …); as built the probe evals->(x) { x.is_a?(IO) }and is handed a pipe end the test owns (checklist deviation 14 already says so).async_transport.rbs:17as the pre-existing over-100-column.rbsline; eight lines from phases 1 and 2 exceed it (async/future.rbs:22,bridge/async_over.rbs:10,http/media_type.rbs:20and:26,http/request_options/builder.rbs:6,http/request_options.rbs:11and:14), none in a file 3a touched.