Skip to content

fix(runtime): implement the buffered-send streaming fallback on both dispatcher write-less-transport branches - #9973

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-9936-sse-fallback-envelope
Aug 19, 2026
Merged

fix(runtime): implement the buffered-send streaming fallback on both dispatcher write-less-transport branches#9973
huangyiirene merged 2 commits into
mainfrom
claude/issue-9936-sse-fallback-envelope

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

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 write in packages/spec/src/contracts/http-server.ts): buffer the SSE frames and deliver them through send() under the streaming headers. packages/spec/** is untouched — this implements the recorded contract, it does not amend it.

Member 1 — #9936, mountRouteOnServer's streaming branch

The 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's parseEventStream and objectui's AI-SDK transport both split raw response bytes on newlines for data: 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 answers send(buffered) under the already-set text/event-stream headers.

Ratchet statement, as ruled: the check-route-envelope.mjs declaration for packages/runtime/src/dispatcher-plugin.ts goes unenveloped: 1 to 0 because 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/main 5989b0de90): "IHttpServer express-style modules — 1 module(s) audited, 10 hand-built body/bodies: 0 conformant, 1 ratcheted (unenveloped 1; ticks down only)". After (this head aa0d23c8fb): "1 module(s) audited, 9 hand-built body/bodies: 1 conformant, 0 ratcheted, 0 exempt", exit 0.

Member 2 — #9961, sendResultBase's dispatch-result writer

The worse site: on a write-less transport it fell through to res.json(result.result), serializing the stream descriptor itself — JSON.stringify collapses the events AsyncIterable 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's null skip and trailing event: error frame) and answers send(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 to check-route-envelope's counters. The pin is the only tripwire; what it catches: a regression back to descriptor-serialization (the json spy fires, send never 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.server providers construct write/end unconditionally; the branches are live only for an external Runtime({ server }) transport omitting the contract's optional streaming surface — measurement on #9936), and no other in-repo test exercises either branch. So dispatcher-plugin.streaming-fallback.test.ts pins 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 streaming res and once against a write-less one, and asserts sent === writes.join(''), then re-asserts it in reader terms by decoding data: lines from the buffered body. A pin that only asserted res.json is 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 (terminal json where send expected, 1 failed / 2 passed); neutralizing member 2's new branch (fall-through to res.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: clean
  • Derived union (node 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. Additionally check:nul-bytes and check:test-source-alias (the new test imports no other workspace package): exit 0.
  • Changeset: .changeset/dispatcher-streaming-fallback-buffered-send.md (patch, @objectstack/runtime; non-breaking, so no ADR-0087 disposition marker is owed and check-adr-0087-registration confirms).

Generated by Claude Code

…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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b3042e3285bf0c310fc3e82be7ba306541871ba5packageMentionDocs.

Which tree this was computed on

This run read content/docs from cede640fbed56c64034cfb2d9e339309a2578cf7 — the merge of head aa0d23c8fb4c286da69a85a39814c7b301e121b9 into base b3042e3285bf0c310fc3e82be7ba306541871ba5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT (family: #9936 + #9961)

Report received GitHub-first on the chain-head card (#9936 comment 5341334692, marker intact). One branch, per-member commits (addfe554e2 = #9936, aa0d23c8fb = #9961), one PR with both Fixes lines. 4 files, no governed path.

⭐ 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 unenveloped: 0 — it became {}. That is a third possibility, and on its face it could have been the worst one: a file still enumerated but declaring nothing, i.e. enforcement quietly removed while the output reads like a tick-down.

It is not. Checked in the gate's own source rather than inferred:

for (const [key, what] of Object.entries(PLUGIN_ROUTE_COUNTERS)) {
const want = declared[key] ?? 0;
if (got[key] === want) continue;

declared[key] ?? 0 means a missing key declares ZERO. PLUGIN_ROUTE_COUNTERS has six keys — unenveloped, errorWithoutMessage, errorCodeNotString, strayKeys, stringError, siblingCode — so {} asserts all six at zero. That is the strictest declaration in the vocabulary, and strictly stronger than the old entry, which left the other five at their defaults while pinning one at 1.

The gate names {} as exactly this idiom in its own NOT-DECLARED text: "If every body it BUILDS is the declared envelope, declare {}." A regression now fails with "found 1, declared 0 — a NEW non-conforming body… Raising the declared number is not the fix." And the file is still read, still scanned, and still errors if it stops writing express-style responses at all.

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; ratcheted 1 → 0 and conformant 0 → 1 are the same fact, because the entry no longer carries a ratchet key and conformant = discovered − exempt − ratcheted.

Encoding fidelity — the detail that was easy to get wrong, and wasn't

Site 1's buffered ternary is byte-identical to site 1's own streamed res.write(…). Site 2's mirrors site 2's — including the null skip and the trailing event: error frame.

⭐ Note what that means: site 1 has no null skip and site 2 does, and the fix preserves that asymmetry rather than harmonising it. Each fallback mirrors its own streamed sibling instead of cross-copying from the other site. Cross-copying would have looked tidier and silently changed one site's behaviour; this is the correct reading of "the same bytes a streaming transport would have written."

Guard ordering is right too: the new if (isStream) block sits after the streaming branch's return and before the generic res.json(result.result), so it catches exactly the write-less case and nothing else.

The pins do the starred property, not the easy one

Each drives the same route twice — once against a streaming res, once against a write-less one — and asserts sent === writes.join(''), then re-asserts it in the reader population's terms via a miniature data:-line decoder. Plus jsonBody undefined, the drained flag, and the streaming headers on both legs.

That is the property I said had to be pinned. A pin asserting only that res.json is absent would stay green on wrong bytes; this one would not. The modelling choice matters as well: write/end are absent on the write-less res, not stubbed — which is how the sites actually feature-detect them.

⚠️ And for #9961 the pin is the entire evidentiary basis, correctly stated as such: that body was a relayedres.json(…), invisible to the gate's counters by design, so no gate number moves for that half. Both branches are unreachable in every in-repo composition. The PR says this plainly instead of letting a green gate imply coverage it does not have.

Two ablations, direction predicted first: restoring res.json({ events }) reddens the #9936 pin (json vs send); neutralising the new branch reddens both #9961 pins. Restored, green.

⭐ Self-reported drift, remedied the right way

my first test draft drifted @objectstack/runtime TEST_DEBT 227 to 228 (one TS6133, a dead parameter); fixed by deleting the parameter, ledger untouched, re-measure green.

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.

⚠️ Instruction conflict — real, resolved, and not the implementer's error

The commits end with Co-Authored-By: Claude Opus 5 …, which is a model identifier in a pushed artifact. My dispatch prohibits that; the harness separately mandates that exact trailer verbatim on every commit. Those two cannot both be satisfied.

The specific literal template wins over the general prohibition, so this stands as-is — and the prohibition was honoured everywhere it could be: PR title, PR body and code comments carry no model identifier. Flagging it as a harness-level contradiction to be resolved above this seat, not as a defect in this PR, and not something to force-push over. Raising it rather than silently picking one was right.

Boundary

packages/spec/** untouched — you implemented the contract, you did not amend it ✅ · no gate weakened ✅ · changeset present (patch, non-breaking, no ADR-0087 marker owed) ✅ · both sites' behaviour changes are confined to the write-less branch, so every shipped composition is byte-unchanged.

⏳ Landing

Build Coresuccess; TypeScript Type Check, Lint & Repo Gates, Test Core (3 shards), Dogfood Regression Gate (3 shards), Temporal Conformancein_progress. in_progress is not a pass — PR stays draft; flip-ready then enqueue on convergence of all six required checks, each read from its own conclusion.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude