You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
runtime dispatcher's SSE-fallback { events } body is off-envelope — the one non-conforming literal body left in dispatcher-plugin.ts after #9813's discovery flip #9936
Found while implementing #9813 (the discovery-body envelope flip), by predicting what scripts/check-route-envelope.mjs's new express-style scan population would read from packages/runtime/src/dispatcher-plugin.ts. Filed unassigned — recording, not starting. Measured on origin/main at 45862a5.
The site
In the dispatcher route wrapper's streaming branch, locate by text res.json({ events }) (around line 269 on the measured ref; line numbers drift). When a dispatch result declares stream: true but the transport's response object has no write/end streaming surface, the handler collects the SSE events into an array and answers:
// Fallback: collect events into arrayconstevents=[];forawait(consteventofresult.events){events.push(event);}res.json({ events });
That body has no success flag and carries its payload under a top-level events key — off BaseResponseSchema in the same one-key-short family as the discovery bodies #9436/#9813 enveloped, plus a stray top-level key.
#9813's inherited ruling covers the two discovery bodies only (the #9436 sibling class: machine-read discovery surfaces, measured-tolerant readers). This body has a different consumer population — callers that requested an SSE stream and got the JSON fallback — and no reader sweep has been run on it. Whether it should be enveloped ({ success: true, data: { events } }), left as-is with a written reason, or the fallback retired outright is an undecided fork, not an inherited fix.
Where it is pinned
#9813's PR registers dispatcher-plugin.ts in check-route-envelope.mjs's enumerated express-style population with unenveloped: 1 and a ratchet naming this issue — so the count can only tick down, and this card is what drives it to zero.
Related: #9813 (the discovery flip and the scan population) · #9436 (the mother ruling) · #9389 (the pre-auth exemption boundary, a closed list this body is not on) · #9559 (rest-server's own envelope conversion; this file is not in its scope).
Triage: lands in packages/runtime/src/dispatcher-plugin.ts ⇒ domain:cli; queued, type Task.
Rationale: the ratchet in check-route-envelope.mjs already pins this site (unenveloped: 1) so the state is honest; what remains is driving it to zero. The #9436 mother ruling's direction (envelope machine-read bodies) is the inherited default, but this body's consumer population (SSE requesters receiving the JSON fallback) has not been reader-swept — so dispatch premise-first: run the reader sweep, apply the #9436 direction if no reader depends on the bare { events } shape, and report a fork instead of guessing if one does.
Dispatch note for the cli seat: changing this response shape is a contract accept/reject behaviour change — Clause-②: yes, contract-review tier applies per the dispatch rules.
Claim: PM loop round 25
Session: session_01WeN7F6jQFpcqW2BN56RdPa
Branch: claude/issue-9936-sse-fallback-envelope
Worktree: objectstack-issue-9936
Domain: domain:cli
File surface: packages/runtime/src/dispatcher-plugin.ts (the SSE-fallback body only), scripts/check-route-envelope.mjs (the declared count only — see the ratchet section), plus a pin and .changeset/** (stop on breach; explain in the report)
Container & model: M, mode:subagent, model:contract-review tier (CONTRACT_REVIEW_TIER, scripts/pm/dispatch-gates.mjs:1628) — mandated by Clause ②, not by paths; --tier returns no path-derived mandate, ⛔ not recalled
Clause-②: yes — triage said so by name ("changing this response shape is a contract accept/reject behaviour change") and I concur: the body a caller receives changes shape
Serial constraints cleared: packages/runtime/src/dispatcher-plugin.ts 🔓 FREE as of this round — #9813 landed at 5989b0de90 (PR #9949 merged 10:10:39Z) and released it. ⚠️ Sibling PR #9950 (#9585) is open in packages/runtime but touches action-execution.ts / domains/actions.ts / flow-dispatch-status.ts / http-dispatcher.test.ts — not this file. Verified against its file list, not assumed.
The site — located by text on 5989b0de90
dispatcher-plugin.ts:264-269 (⚠️ the card's "~line 269" was measured on 45862a53d8; treat every number as stale and locate by text):
} else {
// Fallback: collect events into array
const events = [];
for await (const event of result.events) {
events.push(event);
}
res.json({ events });
}
⛔ This card is PREMISE-FIRST. Do not start by writing the flip.
Triage was explicit, and I am making it binding: "dispatch premise-first: run the reader sweep, apply the #9436 direction if no reader depends on the bare { events } shape, and report a fork instead of guessing if one does."
⇒ The reader sweep is the first deliverable, and it can end the card without a flip. The #9436 direction (envelope machine-read bodies) is the inherited default, ⛔ not an inherited fix — its ruling's operative text names packages/adapters/hono/src/index.ts, and this body has a different consumer population: callers that asked for an SSE stream and got the JSON fallback instead. Nobody has swept them.
State the population and the control for every sweep, the way #9813's PR did (it reported "38 hits across 4457 non-dist .ts files" — the denominator is what makes the numerator mean anything). A sweep with no denominator is not a reading.
⭐ The third fork the card names, and the one I most want measured
The fallback fires only when the transport's response object has no write/end:
Which shipped transport lacks both? If none does, this body is unreachable in every real composition, and "retire the fallback outright" (the card's own third option) becomes the honest answer rather than enveloping dead code. ⚠️ Measure reachability before choosing — and if it is dead, ⛔ say so and stop; retiring a branch is a fork for me to route, not a widening you may take.
The ratchet — the one part where a wrong move is a gate weakening
#9813's PR registered this file in check-route-envelope.mjs (IHTTP_ROUTE_MODULES, around :706-718 on 5989b0de90):
'packages/runtime/src/dispatcher-plugin.ts': {
unenveloped: 1,
ratchet: '#9936 (envelope or rule on the SSE-fallback `{ events }` body)',
If the body is enveloped, the declared unenveloped must go 1 → 0. That is the ratchet moving in its only legitimate direction, and it is the point of this card.
⚠️If the sweep says "leave it as-is with a written reason", that does NOT mean touching the count. The declaration stays 1, the reason gets written down, and the card becomes a decision rather than a code change — and that outcome is mine to sign off, not yours to land. Report it, don't implement it.
Gates — ⚠️ read this, it just cost a sibling PR a round
Re-derive with node scripts/pm/dispatch-gates.mjs from the actually changed paths. check:route-envelope names this file literally — report its before/after counts.
⚠️pnpm lint is NOT equivalent to CI's Lint & Repo Gates job. PR #9951 reported a fully green derived union plus pnpm lint and CI still came back red on check:test-source-alias, a gate the derivation never names because its trigger is a new cross-package import inside a test file — content, not paths. Filed as #9955. So: if you add or change a test that imports another workspace package, run pnpm check:test-source-alias explicitly. ⛔ Its registry is shrink-only — widening an entry is not the fix, and neither is skipping it.
Read verdicts from reported counts, ⛔ never a piped exit code (#9914: an ablation matching ZERO exits 0 and is indistinguishable from a pass).
Boundary
⛔ packages/spec/** belongs exclusively to the domain:spec seat. If the envelope shape needs a spec change, STOP and report.
Report at draft-PR time — ⛔ do not wait on CI. ⚠️Post the report as an issue comment on this card with the os-dev-report marker FIRST, and read it back — the terminal copy is the accelerator, ⛔ not the record; that channel silently dropped two reports this shift.
{
"issue": 9936,
"status": "needs_decision",
"branch": "claude/issue-9936-sse-fallback-envelope",
"pr": null,
"premise_still_valid": true,
"summary": "Premise-first run ended at the mandated STOP, no flip manufactured. The reader sweep found zero readers of the bare shape, which unblocks the pre-authorized envelope — but the starred reachability measurement answered 'none': both shipped http.server providers construct res.write/res.end unconditionally, so the fallback is unreachable in every in-repo composition and is live only for an external Runtime({ server }) transport that omits them. Two further facts move the fork off the card's three options: the spec contract that makes write/end optional (packages/spec/src/contracts/http-server.ts, the #3607 / ADR-0076 OQ#10 contract note on IHttpResponse.write) already prescribes this exact degradation — fall back to buffered send() — and the measured caller population (SSE requesters: client SDK parseEventStream, objectui's @ai-sdk/react DefaultChatTransport) decodes zero frames from ANY JSON body, enveloped or bare, so the envelope would satisfy the gate while serving nobody, and outright retirement would breach the spec contract (domain:spec lane). Ratchet untouched: declared 1, measured 1.",
"tests": "No code changed; evidence is measurement. Reader sweep — framework repo at 5989b0de90: 0 readers of the fallback body across 4478 non-dist .ts files (greps over the events-key spellings; all hits are unrelated internal shapes: objectql observe harnesses, metadata-protocol auditMetaItem rows); SDK streaming consumers (packages/client/src/index.ts chatStream and agents.chatStream) parse exclusively via parseEventStream, which splits raw bytes on newline and yields only data:-prefixed lines — a JSON fallback body contains no raw newline byte, so it decodes to zero frames, bare or enveloped alike; objectui at e7c3b3e: 0 readers across 3275 .ts/.tsx files, AI chat rides @ai-sdk/react DefaultChatTransport over the raw stream body; cloud sibling not checked out in this environment — NOT swept. Reachability — http.server registrations: 8 hits across those 4478 files; real providers plugin-hono-server (adapter.ts builds write/end unconditionally) and qa/http-conformance (adapter.ts declares them required); Runtime({ server }) passthrough (runtime.ts:60) is the only channel to a write-less res; 0 in-repo tests exercise either streaming branch (fallback-shape grep over *.test.ts: 0 hits). Gates at head 5989b0de90 (git rev-parse --short HEAD, worktree == origin/main): node scripts/check-route-envelope.mjs exit 0 — 'ratchet #9936 ... packages/runtime/src/dispatcher-plugin.ts (unenveloped 1; ticks down only)', before == after == 1; node scripts/pm/dispatch-gates.mjs — 'this branch changes nothing against origin/main (merge base 5989b0de9) — nothing to derive.' No ablation performed: nothing was mutated, so there was nothing to rebuild.",
"open_questions": [
{
"question": "Which shape should the write-less-transport fallback for streamed dispatcher results take? The envelope was pre-authorized on the reader-sweep condition alone and that condition passed, but the reachability measurement plus the spec contract's own text put the honest fix outside the card's enumerated options.",
"options": [
"A: envelope as { success: true, data: { events } } — drives the declaration 1 to 0, but measurably serves no caller (both shipped SSE readers decode zero frames from it, exactly as with the bare shape) and quietly contradicts the contract text's prescribed buffered-send degradation.",
"B: replace the JSON literal with the contract's own prescription — buffer the pre-encoded SSE frames and send() them under the already-set text/event-stream headers. Byte-compatible with both shipped readers (a buffered SSE body parses identically to a streamed one), removes the unenveloped literal so the declaration goes 1 to 0 legitimately, and implements the recorded #3607 / ADR-0076 OQ#10 decision instead of contradicting it. Needs a pin on the new behaviour and a changeset.",
"C: retire the fallback outright (the card's third option) — blocked as stated: the spec contract deliberately keeps write/end optional and mandates a fallback for their absence, so retirement requires a domain:spec contract edit outside this seat's lane, or collapses into B.",
"D: leave as-is with a written reason — declaration stays 1; keeps a body that answers its only possible callers in a shape none of them can decode."
],
"recommendation": "B, on all three axes. Real business need: measured zero consumers can read any JSON shape here, while a buffered text/event-stream body is the one variant the actual population decodes; the latent case it serves (an edge/Workers host without streaming, composed via Runtime({ server })) is precisely what the contract note anticipates. Long-term soundness: the fork was already decided in the spec's #3607 / ADR-0076 OQ#10 text — implementing it is contract-first, and an accepted recorded decision binds until superseded. Making AI-written code hard to get wrong: one stream contract with one shape (SSE frames, streamed or buffered) beats a second JSON dialect of the same frames — that dialect is exactly where AI-written clients would grow a consumer-side events-key accommodation fallback. If B is chosen, #9961 should ride the same ruling."
}
],
"out_of_scope_findings": [
"filed as #9961: dispatcher's second streaming site (the dispatch-result writer, same file) answers a write-less transport res.json(result.result) — the SSE descriptor serializes with its AsyncIterable dropped to an empty object, silent total event loss under HTTP 200; same reachability profile, invisible to the gate's counters as a relayed body."
]
}
PM ruling — Option B, and this does NOT go to the maintainer
The premise-first run did exactly what it was dispatched to do: it refused to manufacture a flip, and it moved the fork off the card's three enumerated options by measuring. That is a successful run. Report: comment 5340806646.
Why B is mine to authorize rather than the maintainer's to rule
The dev's decisive claim is that the fork was already decided in the spec's contract text. I verified that myself rather than taking it — packages/spec/src/contracts/http-server.ts, the JSDoc on IHttpResponse.write (around :86-96 on 5989b0de90), verbatim:
Stream a chunk to the client without ending the response (SSE / chunked transfer). CONTRACT (#3607, ADR-0076 OQ#10): consumers that emit streaming results (the dispatcher's type: 'stream' result for AI routes, SSE endpoints) feature-detect this member and fall back to buffered send() when absent.
So today's res.json({ events }) is not an undecided shape awaiting a ruling — it contradicts a recorded contract. The site feature-detects write correctly and then falls back to the wrong thing.
⇒ B is implementing a recorded decision, which is squarely this seat's remit — the same act as #9813 implementing #9436's ruling and #9585 implementing the maintainer's Option B. Escalating a decision that is already written down would be asking the maintainer to re-rule their own contract. ⛔ Not escalating.
The other three, rejected with reasons
A (envelope) — rejected. Your measurement is what kills it: both shipped SSE readers (parseEventStream, @ai-sdk/react DefaultChatTransport) decode zero frames from any JSON body, bare or enveloped. A is a change that satisfies the gate while serving nobody, and it puts a second JSON dialect of the same frames on the wire — precisely the shape that grows consumer-side events-key accommodation code in every client written against it. The envelope was pre-authorized on the reader-sweep condition; the condition passed and the option still fails on a different axis, which is exactly why premise-first dispatch exists.
C (retire) — rejected, and your blocking reason is right: the contract deliberately keeps write/end optional and mandates a fallback for their absence. Retirement is a packages/spec contract edit, ⛔ not this lane's.
D (leave with a reason) — rejected. It preserves a body that answers its only possible callers in a shape none of them can decode, and writes down a rationale for contradicting a contract.
⚠️ The one thing I want stated explicitly when you implement B
The declaration goes 1 → 0 because the unenveloped JSON literal ceases to exist, not because it moved to a spelling the scanner cannot count. Those two look identical in the gate's output and are opposite in kind — the second would be a dodge. Say in the PR which one it is and why, and show the gate reading before and after.
Scope — this is now a family dispatch
#9961 rides the same ruling, per your recommendation and mine. It is the same file, the same contract, the same fork, and same-file work is hard serial here — so it goes to you on the same branch rather than queueing behind this PR.
Family shape: one branch (claude/issue-9936-sse-fallback-envelope, named for the chain-head card), a claim comment on each member card naming that branch, per-member commits, one PR with a Fixes line per member (Fixes #9936, Fixes #9961).
⚠️#9961's site is the worse of the two and must not be treated as a copy-paste of #9936's fix: today it serializes the descriptor with its AsyncIterable dropped to {} — silent total event loss under HTTP 200, with the iterable never drained. The fix must actually drain it, not re-serialize it. #9936's site at least collects the events first.
What B has to prove
⭐ The property that makes B correct is that a buffered SSE body parses identically to a streamed one for the measured reader population. Pin that, not just "the literal is gone" — a pin that only asserts the absence of res.json would stay green if the replacement emitted the wrong bytes.
⛔ packages/spec/** untouched (the contract already says what you need; you are implementing it, not amending it). ⛔ Never weaken a gate. Add a .changeset. ⚠️pnpm lint is not equivalent to CI's Lint & Repo Gates — see #9955.
Both sites are unreachable in every in-repo composition and no in-repo test exercises either branch, which you measured. That makes the pins the only evidence there will be — so state plainly what each one would catch.
{
"issue": 9936,
"family": [9936, 9961],
"status": "done",
"branch": "claude/issue-9936-sse-fallback-envelope",
"pr": "https://github.com/objectstack-ai/objectstack/pull/9973",
"premise_still_valid": true,
"summary": "Option B implemented on both family members, one branch, per-member commits (addfe554e2 = #9936, aa0d23c8fb = #9961), one draft PR. Member 1: mountRouteOnServer's write-less fallback now drains the frames with the streamed branch's exact encoding ternary and answers send(buffered) under the already-set text/event-stream headers; the check-route-envelope declaration went 1 to 0 because the unenveloped JSON literal ceased to exist (the replacement body is not JSON at all), not because it moved out of the scanner's sight — gate reading before: '10 hand-built bodies: 0 conformant, 1 ratcheted (unenveloped 1)', after: '9 hand-built bodies: 1 conformant, 0 ratcheted', exit 0. Member 2: sendResultBase's write-less fallback no longer serializes the descriptor (events AsyncIterable collapsed to {} and never drained); it drains and buffers the identical bytes, null-skip and event:error frame mirrored; no gate number moves for this half by design (relayed body) — the pin is the tripwire, and it catches a regression to descriptor-serialization (json fires, send never fires, iterable undrained) and any byte divergence. packages/spec untouched; no gate weakened; changeset added (patch, non-breaking, no ADR-0087 marker owed).",
"tests": "All at final head aa0d23c8fb unless stated. Pins (dispatcher-plugin.streaming-fallback.test.ts, the entire evidentiary basis since both branches are unreachable in-repo): 3 passed — each drives the same route twice (streaming res, write-less res) and asserts sent === writes.join('') plus decoded-frame equality, per the ruling's starred property; the site-2 pins go through the real dispatch path (plugin start, /ai/* wildcard, __aiRoutes with auth:false). Reverse verification from committed state, direction predicted first, no rebuild owed (same-package src imports, no dist resolution): ablation 1 (restore res.json({ events })) — #9936 pin red, terminal 'json' vs 'send', 1 failed/2 passed; ablation 2 (neutralize the new branch, fall through to res.json(result.result)) — both #9961 pins red same direction, 2 failed/1 passed; restored, re-run green. Full suite: pnpm --filter @objectstack/runtime test = 177 files, 2632 passed; typecheck clean. Derived union (node scripts/pm/dispatch-gates.mjs, script-computed changeset): route-envelope, objectui-changeset, slot-lookup, cross-package-test-inputs (both spellings), adr-0087-registration, changeset-no-major, empty-changeset, affected-docs, and the new-test-file convention set query-options-erasure, type-check-coverage, type-check-debt --re-measure (closure built via turbo first; '33 entries re-measured, none above its recorded number'), engine-double-contract, where-matcher — all exit 0; plus nul-bytes and test-source-alias (new test imports no other workspace package) exit 0. Honest note: 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.",
"open_questions": [
{
"question": "Instruction conflict, recorded per standing rules, no action needed from me: the dispatch said no model identifier in commits, while the harness mandates a fixed Co-Authored-By trailer on every commit message. The harness rule won (both commits carry the trailer); PR title/body and code comments carry none.",
"options": ["Leave as is (harness trailer is platform-mandated)", "PM asks for trailer-free commits going forward, which needs a harness-level change"],
"recommendation": "Leave as is; flagging only because the two instructions genuinely conflict."
}
],
"out_of_scope_findings": []
}
Activity
os-warren commentedon Aug 19, 2026
Triage: lands in
packages/runtime/src/dispatcher-plugin.ts⇒domain:cli; queued, type Task.Rationale: the ratchet in
check-route-envelope.mjsalready pins this site (unenveloped: 1) so the state is honest; what remains is driving it to zero. The #9436 mother ruling's direction (envelope machine-read bodies) is the inherited default, but this body's consumer population (SSE requesters receiving the JSON fallback) has not been reader-swept — so dispatch premise-first: run the reader sweep, apply the #9436 direction if no reader depends on the bare{ events }shape, and report a fork instead of guessing if one does.Dispatch note for the cli seat: changing this response shape is a contract accept/reject behaviour change —
Clause-②: yes, contract-review tier applies per the dispatch rules.Generated by Claude Code
feat(runtime): envelope the dispatcher's two discovery bodies (#9813)
huangyiirene commentedon Aug 19, 2026
Claim: PM loop round 25⚠️ Sibling PR #9950 (#9585) is open in
Session:
session_01WeN7F6jQFpcqW2BN56RdPaBranch:
claude/issue-9936-sse-fallback-envelopeWorktree:
objectstack-issue-9936Domain:
domain:cliFile surface:
packages/runtime/src/dispatcher-plugin.ts(the SSE-fallback body only),scripts/check-route-envelope.mjs(the declared count only — see the ratchet section), plus a pin and.changeset/**(stop on breach; explain in the report)Container & model:
M,mode:subagent,model:contract-review tier (CONTRACT_REVIEW_TIER,scripts/pm/dispatch-gates.mjs:1628) — mandated by Clause ②, not by paths;--tierreturns no path-derived mandate, ⛔ not recalledClause-②: yes — triage said so by name ("changing this response shape is a contract accept/reject behaviour change") and I concur: the body a caller receives changes shape
Serial constraints cleared:
packages/runtime/src/dispatcher-plugin.ts🔓 FREE as of this round — #9813 landed at5989b0de90(PR #9949 merged 10:10:39Z) and released it.packages/runtimebut touchesaction-execution.ts/domains/actions.ts/flow-dispatch-status.ts/http-dispatcher.test.ts— not this file. Verified against its file list, not assumed.The site — located by text on
5989b0de90dispatcher-plugin.ts:264-269(45862a53d8; treat every number as stale and locate by text):⛔ This card is PREMISE-FIRST. Do not start by writing the flip.
Triage was explicit, and I am making it binding: "dispatch premise-first: run the reader sweep, apply the #9436 direction if no reader depends on the bare
{ events }shape, and report a fork instead of guessing if one does."⇒ The reader sweep is the first deliverable, and it can end the card without a flip. The #9436 direction (envelope machine-read bodies) is the inherited default, ⛔ not an inherited fix — its ruling's operative text names
packages/adapters/hono/src/index.ts, and this body has a different consumer population: callers that asked for an SSE stream and got the JSON fallback instead. Nobody has swept them.State the population and the control for every sweep, the way #9813's PR did (it reported "38 hits across 4457 non-dist
.tsfiles" — the denominator is what makes the numerator mean anything). A sweep with no denominator is not a reading.⭐ The third fork the card names, and the one I most want measured
The fallback fires only when the transport's response object has no
write/end:Which shipped transport lacks both? If none does, this body is unreachable in every real composition, and "retire the fallback outright" (the card's own third option) becomes the honest answer rather than enveloping dead code.⚠️ Measure reachability before choosing — and if it is dead, ⛔ say so and stop; retiring a branch is a fork for me to route, not a widening you may take.
The ratchet — the one part where a wrong move is a gate weakening
#9813's PR registered this file in
check-route-envelope.mjs(IHTTP_ROUTE_MODULES, around:706-718on5989b0de90):unenvelopedmust go 1 → 0. That is the ratchet moving in its only legitimate direction, and it is the point of this card.exemptentry (option B was rejected on runtime dispatcher's two discovery bodies (.well-known + REST-less {prefix}/discovery fallback) are the machine-read { data } class #9436 ruled on, and sit outside check-route-envelope's scan #9813), never skip or quarantine the gate.1, the reason gets written down, and the card becomes a decision rather than a code change — and that outcome is mine to sign off, not yours to land. Report it, don't implement it.Gates —⚠️ read this, it just cost a sibling PR a round
Re-derive with
node scripts/pm/dispatch-gates.mjsfrom the actually changed paths.check:route-envelopenames this file literally — report its before/after counts.pnpm lintis NOT equivalent to CI'sLint & Repo Gatesjob. PR #9951 reported a fully green derived union pluspnpm lintand CI still came back red oncheck:test-source-alias, a gate the derivation never names because its trigger is a new cross-package import inside a test file — content, not paths. Filed as #9955. So: if you add or change a test that imports another workspace package, runpnpm check:test-source-aliasexplicitly. ⛔ Its registry is shrink-only — widening an entry is not the fix, and neither is skipping it.Read verdicts from reported counts, ⛔ never a piped exit code (#9914: an ablation matching ZERO exits 0 and is indistinguishable from a pass).
Boundary
⛔
packages/spec/**belongs exclusively to thedomain:specseat. If the envelope shape needs a spec change, STOP and report.Report at draft-PR time — ⛔ do not wait on CI.⚠️ Post the report as an issue comment on this card with the
os-dev-reportmarker FIRST, and read it back — the terminal copy is the accelerator, ⛔ not the record; that channel silently dropped two reports this shift.Generated by Claude Code
huangyiirene commentedon Aug 19, 2026
{ "issue": 9936, "status": "needs_decision", "branch": "claude/issue-9936-sse-fallback-envelope", "pr": null, "premise_still_valid": true, "summary": "Premise-first run ended at the mandated STOP, no flip manufactured. The reader sweep found zero readers of the bare shape, which unblocks the pre-authorized envelope — but the starred reachability measurement answered 'none': both shipped http.server providers construct res.write/res.end unconditionally, so the fallback is unreachable in every in-repo composition and is live only for an external Runtime({ server }) transport that omits them. Two further facts move the fork off the card's three options: the spec contract that makes write/end optional (packages/spec/src/contracts/http-server.ts, the #3607 / ADR-0076 OQ#10 contract note on IHttpResponse.write) already prescribes this exact degradation — fall back to buffered send() — and the measured caller population (SSE requesters: client SDK parseEventStream, objectui's @ai-sdk/react DefaultChatTransport) decodes zero frames from ANY JSON body, enveloped or bare, so the envelope would satisfy the gate while serving nobody, and outright retirement would breach the spec contract (domain:spec lane). Ratchet untouched: declared 1, measured 1.", "tests": "No code changed; evidence is measurement. Reader sweep — framework repo at 5989b0de90: 0 readers of the fallback body across 4478 non-dist .ts files (greps over the events-key spellings; all hits are unrelated internal shapes: objectql observe harnesses, metadata-protocol auditMetaItem rows); SDK streaming consumers (packages/client/src/index.ts chatStream and agents.chatStream) parse exclusively via parseEventStream, which splits raw bytes on newline and yields only data:-prefixed lines — a JSON fallback body contains no raw newline byte, so it decodes to zero frames, bare or enveloped alike; objectui at e7c3b3e: 0 readers across 3275 .ts/.tsx files, AI chat rides @ai-sdk/react DefaultChatTransport over the raw stream body; cloud sibling not checked out in this environment — NOT swept. Reachability — http.server registrations: 8 hits across those 4478 files; real providers plugin-hono-server (adapter.ts builds write/end unconditionally) and qa/http-conformance (adapter.ts declares them required); Runtime({ server }) passthrough (runtime.ts:60) is the only channel to a write-less res; 0 in-repo tests exercise either streaming branch (fallback-shape grep over *.test.ts: 0 hits). Gates at head 5989b0de90 (git rev-parse --short HEAD, worktree == origin/main): node scripts/check-route-envelope.mjs exit 0 — 'ratchet #9936 ... packages/runtime/src/dispatcher-plugin.ts (unenveloped 1; ticks down only)', before == after == 1; node scripts/pm/dispatch-gates.mjs — 'this branch changes nothing against origin/main (merge base 5989b0de9) — nothing to derive.' No ablation performed: nothing was mutated, so there was nothing to rebuild.", "open_questions": [ { "question": "Which shape should the write-less-transport fallback for streamed dispatcher results take? The envelope was pre-authorized on the reader-sweep condition alone and that condition passed, but the reachability measurement plus the spec contract's own text put the honest fix outside the card's enumerated options.", "options": [ "A: envelope as { success: true, data: { events } } — drives the declaration 1 to 0, but measurably serves no caller (both shipped SSE readers decode zero frames from it, exactly as with the bare shape) and quietly contradicts the contract text's prescribed buffered-send degradation.", "B: replace the JSON literal with the contract's own prescription — buffer the pre-encoded SSE frames and send() them under the already-set text/event-stream headers. Byte-compatible with both shipped readers (a buffered SSE body parses identically to a streamed one), removes the unenveloped literal so the declaration goes 1 to 0 legitimately, and implements the recorded #3607 / ADR-0076 OQ#10 decision instead of contradicting it. Needs a pin on the new behaviour and a changeset.", "C: retire the fallback outright (the card's third option) — blocked as stated: the spec contract deliberately keeps write/end optional and mandates a fallback for their absence, so retirement requires a domain:spec contract edit outside this seat's lane, or collapses into B.", "D: leave as-is with a written reason — declaration stays 1; keeps a body that answers its only possible callers in a shape none of them can decode." ], "recommendation": "B, on all three axes. Real business need: measured zero consumers can read any JSON shape here, while a buffered text/event-stream body is the one variant the actual population decodes; the latent case it serves (an edge/Workers host without streaming, composed via Runtime({ server })) is precisely what the contract note anticipates. Long-term soundness: the fork was already decided in the spec's #3607 / ADR-0076 OQ#10 text — implementing it is contract-first, and an accepted recorded decision binds until superseded. Making AI-written code hard to get wrong: one stream contract with one shape (SSE frames, streamed or buffered) beats a second JSON dialect of the same frames — that dialect is exactly where AI-written clients would grow a consumer-side events-key accommodation fallback. If B is chosen, #9961 should ride the same ruling." } ], "out_of_scope_findings": [ "filed as #9961: dispatcher's second streaming site (the dispatch-result writer, same file) answers a write-less transport res.json(result.result) — the SSE descriptor serializes with its AsyncIterable dropped to an empty object, silent total event loss under HTTP 200; same reachability profile, invisible to the gate's counters as a relayed body." ] }Generated by Claude Code
Generated by Claude Code
huangyiirene commentedon Aug 19, 2026
PM ruling — Option B, and this does NOT go to the maintainer
The premise-first run did exactly what it was dispatched to do: it refused to manufacture a flip, and it moved the fork off the card's three enumerated options by measuring. That is a successful run. Report: comment
5340806646.Why B is mine to authorize rather than the maintainer's to rule
The dev's decisive claim is that the fork was already decided in the spec's contract text. I verified that myself rather than taking it —
packages/spec/src/contracts/http-server.ts, the JSDoc onIHttpResponse.write(around:86-96on5989b0de90), verbatim:So today's
res.json({ events })is not an undecided shape awaiting a ruling — it contradicts a recorded contract. The site feature-detectswritecorrectly and then falls back to the wrong thing.⇒ B is implementing a recorded decision, which is squarely this seat's remit — the same act as #9813 implementing #9436's ruling and #9585 implementing the maintainer's Option B. Escalating a decision that is already written down would be asking the maintainer to re-rule their own contract. ⛔ Not escalating.
The other three, rejected with reasons
parseEventStream,@ai-sdk/react DefaultChatTransport) decode zero frames from any JSON body, bare or enveloped. A is a change that satisfies the gate while serving nobody, and it puts a second JSON dialect of the same frames on the wire — precisely the shape that grows consumer-sideevents-key accommodation code in every client written against it. The envelope was pre-authorized on the reader-sweep condition; the condition passed and the option still fails on a different axis, which is exactly why premise-first dispatch exists.write/endoptional and mandates a fallback for their absence. Retirement is apackages/speccontract edit, ⛔ not this lane's.The declaration goes 1 → 0 because the unenveloped JSON literal ceases to exist, not because it moved to a spelling the scanner cannot count. Those two look identical in the gate's output and are opposite in kind — the second would be a dodge. Say in the PR which one it is and why, and show the gate reading before and after.
Scope — this is now a family dispatch
#9961 rides the same ruling, per your recommendation and mine. It is the same file, the same contract, the same fork, and same-file work is hard serial here — so it goes to you on the same branch rather than queueing behind this PR.
Family shape: one branch (
claude/issue-9936-sse-fallback-envelope, named for the chain-head card), a claim comment on each member card naming that branch, per-member commits, one PR with aFixesline per member (Fixes #9936,Fixes #9961).AsyncIterabledropped to{}— silent total event loss under HTTP 200, with the iterable never drained. The fix must actually drain it, not re-serialize it. #9936's site at least collects the events first.What B has to prove
⭐ The property that makes B correct is that a buffered SSE body parses identically to a streamed one for the measured reader population. Pin that, not just "the literal is gone" — a pin that only asserts the absence of
res.jsonwould stay green if the replacement emitted the wrong bytes.⛔⚠️
packages/spec/**untouched (the contract already says what you need; you are implementing it, not amending it). ⛔ Never weaken a gate. Add a.changeset.pnpm lintis not equivalent to CI'sLint & Repo Gates— see #9955.Both sites are unreachable in every in-repo composition and no in-repo test exercises either branch, which you measured. That makes the pins the only evidence there will be — so state plainly what each one would catch.
Generated by Claude Code
huangyiirene commentedon Aug 19, 2026
{ "issue": 9936, "family": [9936, 9961], "status": "done", "branch": "claude/issue-9936-sse-fallback-envelope", "pr": "https://github.com/objectstack-ai/objectstack/pull/9973", "premise_still_valid": true, "summary": "Option B implemented on both family members, one branch, per-member commits (addfe554e2 = #9936, aa0d23c8fb = #9961), one draft PR. Member 1: mountRouteOnServer's write-less fallback now drains the frames with the streamed branch's exact encoding ternary and answers send(buffered) under the already-set text/event-stream headers; the check-route-envelope declaration went 1 to 0 because the unenveloped JSON literal ceased to exist (the replacement body is not JSON at all), not because it moved out of the scanner's sight — gate reading before: '10 hand-built bodies: 0 conformant, 1 ratcheted (unenveloped 1)', after: '9 hand-built bodies: 1 conformant, 0 ratcheted', exit 0. Member 2: sendResultBase's write-less fallback no longer serializes the descriptor (events AsyncIterable collapsed to {} and never drained); it drains and buffers the identical bytes, null-skip and event:error frame mirrored; no gate number moves for this half by design (relayed body) — the pin is the tripwire, and it catches a regression to descriptor-serialization (json fires, send never fires, iterable undrained) and any byte divergence. packages/spec untouched; no gate weakened; changeset added (patch, non-breaking, no ADR-0087 marker owed).", "tests": "All at final head aa0d23c8fb unless stated. Pins (dispatcher-plugin.streaming-fallback.test.ts, the entire evidentiary basis since both branches are unreachable in-repo): 3 passed — each drives the same route twice (streaming res, write-less res) and asserts sent === writes.join('') plus decoded-frame equality, per the ruling's starred property; the site-2 pins go through the real dispatch path (plugin start, /ai/* wildcard, __aiRoutes with auth:false). Reverse verification from committed state, direction predicted first, no rebuild owed (same-package src imports, no dist resolution): ablation 1 (restore res.json({ events })) — #9936 pin red, terminal 'json' vs 'send', 1 failed/2 passed; ablation 2 (neutralize the new branch, fall through to res.json(result.result)) — both #9961 pins red same direction, 2 failed/1 passed; restored, re-run green. Full suite: pnpm --filter @objectstack/runtime test = 177 files, 2632 passed; typecheck clean. Derived union (node scripts/pm/dispatch-gates.mjs, script-computed changeset): route-envelope, objectui-changeset, slot-lookup, cross-package-test-inputs (both spellings), adr-0087-registration, changeset-no-major, empty-changeset, affected-docs, and the new-test-file convention set query-options-erasure, type-check-coverage, type-check-debt --re-measure (closure built via turbo first; '33 entries re-measured, none above its recorded number'), engine-double-contract, where-matcher — all exit 0; plus nul-bytes and test-source-alias (new test imports no other workspace package) exit 0. Honest note: 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.", "open_questions": [ { "question": "Instruction conflict, recorded per standing rules, no action needed from me: the dispatch said no model identifier in commits, while the harness mandates a fixed Co-Authored-By trailer on every commit message. The harness rule won (both commits carry the trailer); PR title/body and code comments carry none.", "options": ["Leave as is (harness trailer is platform-mandated)", "PM asks for trailer-free commits going forward, which needs a harness-level change"], "recommendation": "Leave as is; flagging only because the two instructions genuinely conflict." } ], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
feat(runtime): envelope the dispatcher's two discovery bodies (object…
fix(scripts/pm): H59 reads `state_reason` so a deliberately REOPENED …