Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): implement the buffered-send streaming fallback on both dispatcher write-less-transport branches - #9973
Conversation
…ts in the dispatcher route wrapper
The mountRouteOnServer streaming branch's fallback answered a bare JSON
{ events } body no SSE reader could decode. It now implements the
IHttpResponse streaming contract's prescription (#3607, ADR-0076 OQ#10):
the same SSE bytes, buffered and delivered through send() under the
streaming headers, pinned byte-identical to the streamed branch. The
route-envelope declaration for dispatcher-plugin.ts goes 1 to 0 because
the unenveloped JSON literal ceased to exist, not because it moved out
of the scanner's sight.
Family chain-head half; the sibling dispatch-result writer follows in
the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa…he dispatch-result writer
sendResultBase's write-less fallback fell through to
res.json(result.result), serializing the stream descriptor with its
events AsyncIterable collapsed to {} — HTTP 200, payload gone, iterable
never drained. It now drains the frames and delivers the identical SSE
bytes through send() under the streaming headers, mirroring the streamed
branch's encoding (null skip and event: error frame included), pinned
byte-identical to it. This body is a relayed res.json(...) invisible to
check-route-envelope's counters by design, so no gate number moves for
this half — the pin is the tripwire.
Family member of the chain-head; same contract prescription (#3607,
ADR-0076 OQ#10).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cede640fbed56c64034cfb2d9e339309a2578cf7 && git checkout cede640fbed56c64034cfb2d9e339309a2578cf7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3042e3285bf0c310fc3e82be7ba306541871ba5 aa0d23c8fb4c286da69a85a39814c7b301e121b9 && git checkout -B drift-repro b3042e3285bf0c310fc3e82be7ba306541871ba5 && git merge --no-ff aa0d23c8fb4c286da69a85a39814c7b301e121b9
node scripts/docs-audit/affected-docs.mjs --json b3042e3285bf0c310fc3e82be7ba306541871ba5 |
huangyiirene
commented
Aug 19, 2026
PM review — ACCEPT (family: #9936 + #9961)Report received GitHub-first on the chain-head card (#9936 comment ⭐ The ratchet — I asked for 1→0-by-deletion, and got a third thing I had not anticipated. It clears.My instruction was that the count must go to zero because the literal ceases to exist, not because it moved to a spelling the scanner cannot count. The entry did not become It is not. Checked in the gate's own source rather than inferred:
The gate names ⇒ Legitimate tick-down, and a net tightening. The reported readings are consistent with this once the categories are read properly: bodies 10 → 9 is the deleted literal; Encoding fidelity — the detail that was easy to get wrong, and wasn'tSite 1's buffered ternary is byte-identical to site 1's own streamed ⭐ Note what that means: site 1 has no Guard ordering is right too: the new The pins do the starred property, not the easy oneEach drives the same route twice — once against a streaming That is the property I said had to be pinned. A pin asserting only that
Two ablations, direction predicted first: restoring ⭐ Self-reported drift, remedied the right way
The wrong fix here — raising the ledger — is a gate weakening, and it would have passed unnoticed. Removing the cause and disclosing the near-miss is the correct handling of both.
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9936
Fixes#9961
Family PR (one branch, per-member commits) implementing the option-B ruling on chain-head #9936: both write-less-transport fallbacks for streamed dispatcher results now implement the IHttpResponse streaming contract's own prescription (#3607, ADR-0076 OQ#10 — the JSDoc on
writeinpackages/spec/src/contracts/http-server.ts): buffer the SSE frames and deliver them throughsend()under the streaming headers.packages/spec/**is untouched — this implements the recorded contract, it does not amend it.Member 1 — #9936,
mountRouteOnServer's streaming branchThe fallback answered a bare
res.json({ events })— an off-envelope JSON dialect of the frames that decodes to zero frames in every reader of the measured population (the client SDK'sparseEventStreamand objectui's AI-SDK transport both split raw response bytes on newlines fordata:lines, and a JSON body contains no raw newline byte). It now drains the iterable into a buffer using the streamed branch's exact frame-encoding ternary and answerssend(buffered)under the already-settext/event-streamheaders.Ratchet statement, as ruled: the
check-route-envelope.mjsdeclaration forpackages/runtime/src/dispatcher-plugin.tsgoesunenveloped: 1to0because the unenveloped JSON literal ceased to exist — the replacement body is not JSON at all — and not because the body moved to a spelling the scanner cannot count. Gate reading before (origin/main5989b0de90): "IHttpServer express-style modules — 1 module(s) audited, 10 hand-built body/bodies: 0 conformant, 1 ratcheted (unenveloped 1; ticks down only)". After (this headaa0d23c8fb): "1 module(s) audited, 9 hand-built body/bodies: 1 conformant, 0 ratcheted, 0 exempt", exit 0.Member 2 — #9961,
sendResultBase's dispatch-result writerThe worse site: on a write-less transport it fell through to
res.json(result.result), serializing the stream descriptor itself —JSON.stringifycollapses theeventsAsyncIterable to{}, so the caller got HTTP 200 with the payload gone and the iterable was never drained. The fix drains the frames (mirroring the streamed branch'snullskip and trailingevent: errorframe) and answerssend(buffered)under the descriptor's streaming headers.No gate number moves for this half, by design: the old body was a relayed
res.json(...), invisible tocheck-route-envelope's counters. The pin is the only tripwire; what it catches: a regression back to descriptor-serialization (thejsonspy fires,sendnever does, the drained flag stays false) and any byte divergence from the streamed branch, error-frame encoding included.The pins — the entire evidentiary basis
Both fallback branches are unreachable in every in-repo composition (both shipped
http.serverproviders constructwrite/endunconditionally; the branches are live only for an externalRuntime({ server })transport omitting the contract's optional streaming surface — measurement on #9936), and no other in-repo test exercises either branch. Sodispatcher-plugin.streaming-fallback.test.tspins the property that makes option B correct: a buffered SSE body is byte-identical to the streamed one — each pin drives the same route twice, once against a streamingresand once against a write-less one, and assertssent === writes.join(''), then re-asserts it in reader terms by decodingdata:lines from the buffered body. A pin that only assertedres.jsonis absent would stay green if the replacement emitted the wrong bytes; these fail on any byte divergence.Reverse verification from the committed state, per ablation with predicted direction stated first (pins run from source via same-package relative import — no dist resolution is involved, so there is no rebuild step to owe): restoring member 1's old
res.json({ events })body turns the #9936 pin red exactly as predicted (terminaljsonwheresendexpected, 1 failed / 2 passed); neutralizing member 2's new branch (fall-through tores.json(result.result)) turns both #9961 pins red the same way (2 failed / 1 passed). Both legs restored and re-run green.Verification at head
aa0d23c8fb(final commit)pnpm --filter @objectstack/runtime test: 177 files, 2632 passed (128s)pnpm --filter @objectstack/runtime typecheck: cleannode scripts/pm/dispatch-gates.mjs, no paths — script-computed changeset):check:route-envelope·check:objectui-changeset·check:slot-lookup·check:cross-package-test-inputs(pnpm + ci.yml node twin) ·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-affected-docs— all exit 0; convention-triggered for a new test file:check:query-options-erasure·check:type-check-coverage·check:type-check-debt --re-measure(workspace closure built first via turbo as lint.yml does; "33 ledger entries re-measured, none above its recorded number" — an interim +1 TS6133 drift my first test draft introduced in@objectstack/runtime's TEST_DEBT was fixed by deleting the dead parameter, not by touching the ledger) ·check:engine-double-contract·check:where-matcher— all exit 0. Additionallycheck:nul-bytesandcheck:test-source-alias(the new test imports no other workspace package): exit 0..changeset/dispatcher-streaming-fallback-buffered-send.md(patch,@objectstack/runtime; non-breaking, so no ADR-0087 disposition marker is owed andcheck-adr-0087-registrationconfirms).Generated by Claude Code