Skip to content

Phase 7b: server-sent events — documentation and phase record - #83

Merged
Wahbeh-Mohammad merged 6 commits into
mainfrom
27-phase-7b-server-sent-events-docs
Sep 20, 2026
Merged

Wahbeh-Mohammad merged 6 commits into
mainfrom
27-phase-7b-server-sent-events-docs

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Closes #27. Third PR of phase 7b's stack — the phase record and the documentation — on top of #82.

What lands

12 files, +1,165 / −41.

  • docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md — 41 own rows: 40 ✅, 1 ⏳ (SSE-41, MAY, declined for v1 — docs/first-release.md § What v1 ships without, whose entry is true as written and untouched), plus 20 cross-reference rows; the roadmap's legend verbatim; sections: requirement rows, what was built, guards run red (50), audit groups run, deviations from the plan (36), findings routed, postponed work.
  • The design's As-built addendum, ledger rows P7-81–P7-86 (Sentinel; the four-method _ByteSource; logger: and the close_quietly route for SSE-30; the eighteenth gate; the 43 public names as P7-27 amended; TypedStream's two drive shapes and the facade's one failure path), beside the design's own P7-20–P7-27, which stand. The §10.18 amendment for the two cap constants and the consolidation into design §10 are a human's — docs/sdk-design-ruby/ is frozen — and the addendum says so.
  • docs/sdk-documentation/sse.md (new; every example executed on 4.0.6 and 3.2.11 before it was written down), architecture.md, io.md (the #read_line_utf8 line-cap sentence corrected beside the YARD), quality-gates.md (the eighteenth gate's row), the core README, README.md, docs/README.md.
  • docs/knowledge/notes/sse-streaming.md (new) — supersedes sse-streaming/2dba42b0, corrects /8c25db7d and /e98a0668, three reference entries (the PAGE-14 attribution, the SSE-11/SSE-19 attribution, the enumerator restart); ruby scripts/verify_knowledge_structure.rb OK.
  • CLAUDE.md — the built-phases sentence gains 7b; the opening paragraph gains the SSE layer; the lib-file count and the mirror clauses re-derived from the tree; fifteen checklists; eighteen gates everywhere seventeen was counted; one line under "Constraints that will bite".
  • The roadmap's phase-7b status note (append-only) and two phase-10 inbound bullets, cited by date and content (the enumerator restart after a mid-stream failure; the #getbyte throughput).

docs/product-spec/, docs/sdk-design-ruby/, docs/knowledge/harvested/, docs/deviations.md and every other phase's documents are untouched.

Concurrency note for the merge

Three sibling lanes (7a #26, 7c #28, 8a #30) are built off the same main and edit the same count sentences, lib/dexpace.rb, the LAYERS table, the manifest and tasks/gates.rake (8a's clean_bundle_check edit; 7b's task block is appended). Whichever stack merges second rebases and re-derives the counts; the gates:serde_boundary page/** row flips from PENDING to GUARDED on the first tip that holds both 7b and 7c.

Verification

Docs tip: bundle exec rake (eighteen gates) green on 4.0.6; ruby .claude/skills/housekeeping/probe.rb exit 0 (all eight checks); ruby scripts/verify_knowledge_structure.rb OK (2,166 harvested, 61 notes); the housekeeping and knowledge tooling suites green; every sse.md block run on both interpreters.

Known follow-ups

R1-1 (nit) — sse.md:107's "copies it again" wording; see the code PR's list.

Phase 7b, chapter 13 (SSE-1 through SSE-40; SSE-41 declined for v1).
Nine new files under lib/dexpace/sse/ with sig/ mirrors: the namespace
with its three limits (MAX_LINE_BYTES 1 MiB, MAX_EVENT_BYTES 8 MiB,
MAX_RETRY_MS 2^31-1) and the two frozen Sentinel singletons SKIP and
DONE; LineReader, the WHATWG line machine over BufferedSource#getbyte
with a one-byte pushback, built there and not over #read_line_utf8
because IO-14 keeps a lone CR as content where SSE-2 terminates on it
(P7-20); the immutable five-field Event (Data plus Model); Reader, the
field machine whose one persistent state is the BOM flag; Stream, the
resource-owning single-pass facade over Closeable's latch, built through
.open/.owning/.borrowing with logger: on each; TypedStream over a
caller-supplied mapper; and the two namespaced errors.

The design's Signal is Sentinel: a bare Signal inside module
Dexpace::SSE would shadow Ruby's ::Signal (P7-81). _ByteSource has four
methods, close included, because the reader closes its peek view
(P7-82). SSE-30's swallowed release failure is reported through
Dexpace.close_quietly(self, logger:) as an http.instrumentation.close
diagnostic (P7-83).

gates:serde_boundary is the eighteenth gate: tools/serde_boundary.rb
scans lib/dexpace/sse.rb, lib/dexpace/sse/** and their sig/ mirrors with
a parsed scan (prism for every require spelling and every constant read
or path, the RBS lexer for type names), asserts every guarded glob
matches a file, and prints the pagination layer's globs as PENDING until
7c lands (P7-84). Wired into DEFAULT_GATES, gates:list, the default
task's pin and CI's once-per-run gates job, with fixtures under
test/fixtures/gates/serde_boundary/.

Also: the smoke suite's SSE_LAYER and PhaseSevenLayers case, the surface
manifest regenerated once (1137 to 1180 rows, every row read against
the object model), and the #read_line_utf8 YARD in io/typed_reads.rb
corrected (SSE-11 to SSE-19, and its false premise replaced).
…xits

Review round 0 of phase 7b, findings R0-1 and R0-2.

Reader#dispatch returned nil for a block with no field seen before
reset_block ran, so the block's byte total carried over every blank
line that closed a fieldless block. A run of unknown-field keep-alives,
NUL ids or rejected retries between real events accumulated into
SSE-19's event cap and tore the stream down with a spurious
LimitExceededError(kind: :event) once their sum crossed
max_event_bytes -- at the shipped 8 MiB, after 8 MiB of keep-alives.
SSE-1 makes a blank line the end of a block whether or not it
dispatched, so dispatch now resets the block either way; the Event is
built by a private build_event only when a field was seen, and the
attr_reader's YARD says "reset at every blank line, dispatching or not".

TypedStream#drive_values had the rescue Stream#drive has and not its
ensure, so an Enumerable method that stops early on #values -- first(n),
take, find, each { break } -- left the resource unreleased (closes 0)
where the same call on Stream#events released (closes 1): the raw
enumerator it drives is parked mid-#next and never reaches the Stream's
own ensure. The design names the two shapes as mirrors, and the plan's
own fence drove Stream#drive and inherited its ensure. drive_values now
closes in an ensure of its own: a loud release on the block-form exit,
a no-op on the clean end, a DONE and both failure paths, whose latch has
already flipped. No public name or signature changes; reader.rbs gains
the one private build_event line.
Fourteen suites under gems/dexpace-core/test/dexpace/sse/ plus the
namespace file's mirror, one per lib/ file and three with no mirror:
matrix_facts_test.rb (the design's eight facts and the build's, a
standing test on every CI row), boundaries_test.rb (SSE-37's and
SSE-38's absences) and documentation_test.rb (the line-cap closure's
prose on both the SSE constants and #read_line_utf8). The grammar
battery is the chapter's own conformance fixtures, the terminator
property test is seeded, every encoding assertion is non-ASCII, the two
caps are proven at a small value and at their real values (over the duck
source, whose #getbyte is a third the cost of BufferedSource's), and
SSE-31's two shapes are both written and deterministic, the blocked-read
one over BufferedSource.wrapping of an IO.pipe.

Three top-level doubles, one object per file: SSEFixtures (the shared
stream_over helper and its fixtures), ScriptedChunked (a chunked body
whose script raises where an Exception sits) and FakeByteSource (the
_ByteSource duck, used to keep P7-27's claim behavioural). The tree's
own FakeResponseBody, FakeChunked, RecoveryFixtures and RecordingSink
answered the plan's other doubles without a new file.

test/gates/serde_boundary_test.rb drives the gate against twenty-one
fixtures and two fixture workspaces: every require spelling, every
constant spelling including ::JSON and self::JSON, a serde type in an
.rbs, a comment and a string that must pass, an unparseable file, a
non-literal feature, and a GUARDED glob that matches nothing.
…rror

Review round 0 of phase 7b, findings R0-1 through R0-3.

reader_test.rb's EventCapTest gains the case the fix for R0-1 needed:
three 5-byte unknown-field blocks, six NUL-id blocks and six rejected-
retry blocks under a cap each block is far below must not add up to a
LimitExceededError across the blank lines that separate them, while the
same lines in ONE block still trip it. Red against the previous
dispatch (an error, kind :event under the 12-byte cap), green now.

typed_stream_test.rb's ViewTest gains two: first(1), take(2), find and
an each { break } on #values each release the resource once, as the
same call on #events does, and a release failure on that exit
propagates as the raw form's does (R0-2). Red against the previous
drive_values (closes 0 in both), green now. stream_test.rb's
LifecycleTest pins the raw half, events.first(2), which sse.md
documented and nothing asserted.

stream_state_error_test.rb is the one-class error's own mirror, which
the checklist cited and the tree did not hold (R0-3): a Dexpace::Error
and a StandardError caught through Module#===, and the two messages
naming SSE-26 and SSE-27. Every non-private lib file under sse/ now has
its test mirror, as the count sentence in CLAUDE.md says.
The checklist, written from the build: forty-one own rows (forty
implemented, SSE-41 declined for v1), the cross-reference rows, the
matrix facts on every interpreter, the guard battery (forty-six
mutations, forty-six caught on 3.2.11 and forty-five on 4.0.6, the one
survivor an equivalent mutant), the audit groups, thirty-six departures
from the plan's text, the findings routed and the postponed work. The
design's ledger gains an As-built addendum, P7-81 through P7-86.

docs/sdk-documentation/sse.md is the as-built page, every example run
on 4.0.6 and 3.2.11; io.md's line-cap sentence and quality-gates.md's
table (row 9, eighteen gates) are corrected; architecture.md, the core
README, README.md and docs/README.md point at the new page.
docs/knowledge/notes/sse-streaming.md files two supersessions and three
references. CLAUDE.md's claim sentences are re-derived from the tree
(193 lib files, fifteen checklists, eighteen gates) and its constraints
list gains four lines. The roadmap gains the 2026-09-20 status note and
two phase-10 inbound bullets: BufferedSource.over's enumerator restarts
after a mid-stream failure, and the per-byte read path's cost.
The checklist's SSE-1, SSE-19 and SSE-25 rows say what the code now
does: the block resets at every blank line, dispatching or not, and an
early-stopping Enumerable method on the typed #values releases the
resource as the same call on #events does; guards 51 and 52 are the
round's two, each the reviewer's control mutation turned red on both
rows against the pre-fix lib. The NFR-13 row and "What was built" carry
the corrected counts -- forty-six new .rb files, twelve suites, twenty
Ruby fixtures, docs/first-release.md untouched -- and Deviations 16, 36
and the new 37 record the missing error-test file, the typed ensure and
the round's fixes per branch.

The design's As-built addendum and P7-86 say the byte total resets at
every blank line and that drive_values carries Stream#drive's ensure;
the roadmap's 7b note gains the round's paragraph. sse.md's two blocks
that read `e` without binding it now show the rescue that binds it, the
Stream and TypedStream sections state the early-exit mirror, and the
typed example demonstrates values.first(1) releasing; every `# =>` on
the page was re-run on 4.0.6 (55 matched) and 3.2.11 (54, plus the
documented NoMethodError wording). CLAUDE.md's SSE constraint names the
typed drive's own ensure.
@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

Copy link
Copy Markdown
Contributor Author

Review record for the phase 7b stack (#81 → #82 → #83)

2 independent reviews, each by a fresh agent with no memory of the previous one, each re-running every gate itself on every tip (4.0.6 every gate individually at the code tip and the full rake at the tests and docs tips; the matrix set on 3.2.11, 3.3.12 and 3.4.10) and applying its own mutations on both interpreters, with a fix round by a fresh agent between each. The stack was cut from main at c53638b.

Round Verdict Blocking Should-fix Nits Mutations (caught) Disposition
0 changes required 1 2 2 49 (44) all 5 addressed in fix round 1
1 (final) approve 0 0 2 58 (54) carried into the PR bodies

Nothing was skipped.

Round 0 → fixed in round 1

  • R0-1 should-fix — gems/dexpace-core/lib/dexpace/sse/reader.rb:142: Event-cap byte total is not reset when a fieldless block ends, so unknown-field keep-alives accumulate across blocks into a spurious LimitExceededError. Fixed on code (afb9faf): Reader#dispatch: event = build_event if @seen; reset_block; event -- the block (five accumulators and the byte total) resets on every blank line, dispatching or not (SSE-1); private build_event added to reader.rb and reader.rbs; attr_reader YARD says 'reset
  • R0-2 should-fix — gems/dexpace-core/lib/dexpace/sse/typed_stream.rb:107: TypedStream#values does not release on a block-form exit (first(n)/take/find/each{break}) while Stream#events does — the documented mirror is not built. Fixed on code (afb9faf): TypedStream#drive_values gains ensure @stream.close (the plan's own fence drove Stream#drive and inherited its ensure; the as-built shape drives the raw enumerator, which is parked mid-#next, so the Stream's ensure never fired). Loud release on the block-for
  • R0-3 blocking — docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md:69: stream_state_error_test.rb does not exist, but the checklist, the report, the commit message, the roadmap note and CLAUDE.md's count sentence all say it does / that every lib file is mirrored in test/. Fixed on tests (8b5bfbd): gems/dexpace-core/test/dexpace/sse/stream_state_error_test.rb written (3 runs / 17 assertions): a Dexpace::Error, a StandardError and Suppressible caught through Module#===, and the two messages naming SSE-26 and SSE-27 from the real raise sites. comm audit at
  • R0-4 nit — docs/work/mvp/phase7/phase7b/2026-09-10-phase7b-server-sent-events-checklist.md:134: Checklist count words are wrong in three places. Fixed on docs (16ebd16): Checklist: NFR-13 row now 'forty-six new .rb files -- nine lib, one tools, one test/gates, twelve suites and three doubles, twenty Ruby fixtures (seventeen under files/, three across the two fixture workspaces)' and 'seventeen new .rbs (nine sig mirrors, eight
  • R0-5 nit — docs/sdk-documentation/sse.md:89: Block 2's e.kind, e.limit # => [:line, 64] is not runnable Ruby and e is never bound on the page. Fixed on docs (16ebd16): sse.md block 2 now reads begin ... rescue Dexpace::SSE::LimitExceededError => e; e.message # => ...; [e.kind, e.limit] # => [:line, 64]; end, and block 6's StreamError case shows rescue Dexpace::StreamError => e before Dexpace.suppressed(e). Every ```rub

Round 1 (final) — approve

  • R1-1 nit — docs/sdk-documentation/sse.md:107: sse.md says #with 'copies [the data list] again'; as built a derived Event shares its parent's frozen list. Experiment on 4.0.6 and 3.2.11: ev = SSE::Event.build(data: [+"a", +"b"]); ev.with(id: "x").data.equal?(ev.data) → true (Model.own is Ractor.make_shareable(copy: true), which hands an already-shareable list back as it is). The checklist's SSE-20 row and the design's As-built addendum both state this correctly ("a derived event may share its parent's frozen list"); the page's sentence "and `#with
  • R1-2 nit — gems/dexpace-core/lib/dexpace/sse/reader.rb:216: The retry width check ignores an 11+-digit zero-padded value whose magnitude is in range — a narrowing SSE-11's enumerated triggers do not name and the checklist does not record. Reader#apply_retry: return if value.bytesize > MAX_RETRY_DIGITS || !DIGITS_ONLY.match?(value) with MAX_RETRY_DIGITS = 10. Measured on 4.0.6 and 3.2.11: retry: 0000000005 → 5, retry: 00000000005 → nil, retry: 02147483647 → nil, retry: 2147483648 → nil (correct). Appendix C's SSE-11 lists four ignore triggers — a leading sign, an embedded non-digit, an empty value, a value exceeding the ma

What the final reviewer verified by experiment, both interpreters

  • (H) R0-1/R0-2 by hand: exp_r01.rb — fieldless-block runs under small caps, and every early-exit shape on Stream#events vs TypedStream#values over a counting resource — unknown-field/NUL-id/rejected-retry runs no longer raise ([["a"], ["b"]], [["b"]], [["b"]], [["b"]]); the one-block counter-case still raises kind=event; events.first(1)=1, values.first(1)=1, each{break}=1/1, take(2)=1,
  • (I1) Event.build(data: [+"a"], retry: 5).with(id: "1") and .with(retry: -1); raw Data#with initialize check — data frozen, element frozen, retry 5, id "1", derived list is the SAME object as the parent's (equal? true) on both rows; with(retry: -1) raises InvalidArgumentError on both; raw Data#with runs the initialize override on
  • (I2) SSE-31 shape B by hand: reader thread parked in stream.each over BufferedSource.wrapping(IO.pipe read end); stream.close from the main thread; then the write-end variant — thread value IOError 'stream closed in another thread', source.closed? true, reader_end.closed? true, stream closed; write-end close instead: thread :returned with [["a"]] delivered, stream and source closed (clean end)
  • (I3) the decode: data: a\xFFb; data: café; and "café".b.encode(UTF-8, invalid/undef: :replace) without the retag — a\xFFb → valid_encoding? true, == "a�b", UTF-8; café == "café" UTF-8; the retag-less form yields "caf��" (5 chars) — the trap the boundary avoids
  • (I4) rbs validate on the 3.2 row — bundle exec rake rbs:validate rc=0 — the retry member and the recursive _ByteSource validate on the floor
  • (5) bundle exec ruby -w on single suite files, no -Itest — stream_state_error_test 3/17, stream_test 39/151, typed_stream_test 29/101, reader_test 62/210, line_reader_test 24/273, event_test 14/71 — support files reached through require_relative
  • (6) pp and p on Dexpace::SSE::SKIP — both print Dexpace::SSE::SKIP (the #pretty_print override works under pp)
  • (7) SSE-39 by hand: ScriptedChunked two events under BufferedSource.over; construct-and-never-iterate, then one next — yielded 0 after construction, 1 after one next
  • (8) the event cap at its real value: 8 × (MAX_LINE_BYTES-6)-byte data lines plus one more line, over FakeByteSource and over BufferedSource, with RSS — raises kind=event limit=8388608 in 7.4 s / 2.4 s over FakeByteSource and 21.7 s / 8.0 s over BufferedSource (machine shared with sibling lanes' gate runs); RSS 40→57 MiB and 39→56 MiB for an 8 MiB input — bounded
  • (9) gates:serde_boundary against the fixture roots and the real tree; gates:list | wc -l; rake -P default — empty_glob root red naming 'gems/dexpace-core/lib/dexpace/sse//*.rb: matches no file'; workspace root red naming 'workspace/.../sse/reader.rb:4: require "json"'; real tree green with the two PENDING page/ rows and '4
  • (I10) Ractor.make_shareable([+"é"], copy: true) under ruby -w — no warning on either row
  • (12) grep read_line_utf8 across gems/*/lib; SSE-11 under lib/dexpace/io — read_line_utf8: only its definition in typed_reads.rb plus comments in dexpace.rb, sse.rb and line_reader.rb — no caller; SSE-11 under io/: one hit, the corrected YARD's 'not SSE-11, which caps the retry field'
  • (D) lib/dexpace.rb's Phase 7b block fully reversed (stream first, sse last), then require 'dexpace' and stream one event — loads and streams [["a"]] — each file requires its own dependencies, so the entry block's internal order is not load-bearing (same as round 0; not a defect)
  • (G) every ```ruby block of docs/sdk-documentation/sse.md run as one script (run_sse_md.rb), each # => compared (inline, next-line and raise forms), with response/bodyless_response built through Reco — 55 matched / 0 mismatched on 4.0.6; 54 / 1 on 3.2.11 — the one is the private-new NoMethodError wording the page's preamble documents
  • (G) CLAUDE.md count sentences re-derived from the docs tip via git ls-tree — 193 lib files beside version.rb, 194 sig mirrors + sig/dexpace.rbs, lib↔sig one for one, exactly the eighteen private_constants + version.rb without a test mirror, 15 checklists, 16 sdk-documentation pages (fifteen writt
  • (E) the corpus note's eight backticked keys resolved with --key at the docs tip — 2dba42b0, 8c25db7d, e98a0668 print [overridden by notes/sse-streaming.md]; 5f4803a0, b94ce49e, dff112ad, 7adc2212, 4d83311b print [cited by notes/sse-streaming.md] — the relations the note's verbs intend
  • (F) SSE-11 width check against zero-padded values (exp_retry_width.rb) — 0000000005 (10 digits) → 5; 00000000005 (11 digits, value 5) → nil; 02147483647 → nil; 2147483648 → nil; a 30-digit zero-padded 1 → nil (R1-2)
  • (F) extra lifecycle probes: Stream#each without a block; typed values after a close between pulls; mixing external and internal iteration on one #events enumerator — each without a block → LocalJumpError at the first event with the resource released (closes=1, closed); typed values.next then stream.close then next → StopIteration, closes=1; en.next then en.to_a → [["b"], ["c"]] close
  • (F4) the thread/pipe and lifecycle tests ten times each under timeout 120 — stream_test -n /SSE-31|SSE-27/ 5 runs ×10 green; matrix_facts -n /SSE-31/ 3 runs ×10 green; typed_stream_test -n /SSE-2/ 7 runs ×10 green; stream_test -n /SSE-2/ 19 runs ×10 green

Tips reviewed at the final round: code afb9faf, tests 8b5bfbd, docs 16ebd16 on main c53638b.

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.

Phase 7b: Server-Sent Events

1 participant