Skip to content

feat(core): request/response body lifecycle. - #32

Merged
Wahbeh-Mohammad merged 3 commits into
3-phase-3from
5-phase-3b-requestresponse-body-lifecycle
Aug 25, 2026
Merged

feat(core): request/response body lifecycle.#32
Wahbeh-Mohammad merged 3 commits into
3-phase-3from
5-phase-3b-requestresponse-body-lifecycle

Conversation

@Wahbeh-Mohammad

@Wahbeh-Mohammad Wahbeh-Mohammad commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the request/response body lifecycle to @dexpace/core, satisfying BODY-1–BODY-37 and HTTP-36–HTTP-52 (minus the file-backed-body cluster, deferred to Phase 8).

Body model

  • Body interface plus five implementations: ByteArrayBody, StringBody, FormUrlEncodedBody, StreamBody, MultipartBody (with MultipartBodyBuilder)
  • materialize(body) — drains a single-use body into a replayable one, race-safe consumed-once guard
  • Exact-length verification on StreamBody.writeTo (EndOfStreamError on a short stream)
  • Multipart framing: shared length/bytes routine, RFC-2046 boundary validation, header quoting/escaping

Request/Response

  • Request.body narrowed from unknown to Body | undefined
  • Response.body: ReadableStream | null, plus text(), bytes(), close(), Symbol.asyncDispose
  • TypedResponse — lazy parsed-response wrapper, parse-once memoized (success and failure), concurrent-first-call serialization, exposes status/headers/protocol/reason/request without touching the body

Logging tees (@internal, unwired until Phase 7)

  • withRequestLogging — tee-on-write with a bounded, clearing tap
  • withResponseLogging — drain-once wrapper with fits-cap and exceeds-cap regimes, cached drain-failure surfacing

Error handling

  • HttpStatusError / toHttpError(response) — 1 MiB bounded error-body buffering, 4xx/5xx only, replayable re-served body, non-consuming preview
  • New leaves ConsumedBodyError, MultipartBoundaryError (+ isBodyError)
  • Retrofit: flattened io/errors.ts's leftover 3-tier shape (IoError children now extend DexpaceError directly), added isIoError

Public API

  • Promotes Body, its variant types/factories, MultipartPart, materialize, TypedResponse, HttpStatusError/toHttpError, ConsumedBodyError/MultipartBoundaryError/isBodyError from packages/core/src/index.ts
  • Concrete body classes exported as types only, never as constructible values
  • core.api.md regenerated; changeset added (minor — pre-1.0 policy)

@Wahbeh-Mohammad Wahbeh-Mohammad self-assigned this Aug 11, 2026
@Wahbeh-Mohammad Wahbeh-Mohammad changed the title 5 phase 3b requestresponse body lifecycle feat(core): request/response body lifecycle. Aug 11, 2026

@OmarAlJarrah OmarAlJarrah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed §6 against the branch, running it rather than reading it. Structurally this is in good shape — all nine blocking gates pass (typecheck, lint, build, api:ci, lint:publish, verify:dual-consumption, verify:seam-1, verify:runtime-floor, audit) and 411 tests are green.

A lot of the hard parts are right. Multipart's declared contentLength matches the bytes actually written to the byte (222 = 222 on a two-part fixture), so HTTP-51's shared-framing-routine requirement is genuinely doing its job. BODY-3's consume-once guard is properly race-safe — setting #consumed before the first await is the correct shape for this runtime. toHttpError gets the whole HTTP-52/BODY-30 cluster right: the 1 MiB cap holds, it keeps draining past the cap to release the connection, buffering happens inside the close-guaranteeing scope, and the isError gate has a genuinely sharp comment about why code < 400 would be wrong for a 6xx. Form encoding is correct (q=a+b&plus=c%2Bd). HTTP-44's null-success and async-rejection memoization both work. And both StreamBody paths consistently leave the caller's stream open — I suspected pipeTo would cancel it and was wrong.

Two of the findings below are security defects with working proofs, and I'd treat them as blocking. The rest are correctness and polish.

One correction to my own probing, in fairness to the code: name and filename are properly defended. My first assertion there was over-strict — the injected text survives as inert characters inside the quoted string, but the CR/LF are stripped and the framing holds. The defense works; it just doesn't extend to mediaType.

header += `; filename="${quoteParam(part.filename)}"`;
header += '\r\n';
if (part.body.mediaType !== undefined)
header += `Content-Type: ${part.body.mediaType}\r\n`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header injection: mediaType is interpolated raw while name and filename go through quoteParam. HTTP-51's framing defense has a hole.

Body.mediaType is a plain unvalidated string, and byteArrayBody(bytes, mediaType) / stringBody(text, mediaType) accept anything. Run on this branch:

const evil = byteArrayBody(new Uint8Array([120]), 'text/plain\r\nX-Injected: pwned');
multipartBody([{name: 'f', body: evil}], 'BOUNDARY');
--BOUNDARY\r\n
Content-Disposition: form-data; name="f"\r\n
Content-Type: text/plain\r\n
X-Injected: pwned\r\n        <-- attacker-controlled header
\r\n

It gets worse, because the value can close the header block outright:

byteArrayBody(data, 'text/plain\r\n\r\nSMUGGLED-BODY\r\n--BOUNDARY--')
Content-Type: text/plain\r\n
\r\n
SMUGGLED-BODY\r\n
--BOUNDARY--\r\n             <-- forged terminator; the real part body lands outside the multipart

That is a full break of the framing HTTP-51 exists to protect — arbitrary headers, arbitrary part content, and a forged closing boundary, all from a media type string. Any code path where a media type is derived from user input (an uploaded file's declared type is the obvious one) is exploitable.

The fix wants to be validation rather than the strip that quoteParam does, since a media type containing CR/LF is never legitimate: reject it at Body construction. MediaType already enforces exactly this under HTTP-26 ("reject a control character or non-ASCII byte ... using the same predicate as outbound header-value validation, so a media type is always header-safe"). Typing the field as MediaType rather than string, or validating through the same predicate in the body factories, closes it at the source and gets HTTP-26 for free.

Worth noting the contentLength invariant does not catch this — renderPartHeader is shared, so the forged bytes are counted and declared length still matches. The wire is consistently, silently wrong.

Comment thread packages/core/src/body/stream-body.ts Outdated
const {done, value} = await reader.read();
if (done) break;
delivered += value.length;
await writer.write(value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop writes everything the stream yields and only checks the count afterwards, so a stream longer than declared overruns Content-Length before the error is raised.

HTTP-39/BODY-10 says the copy "MUST write precisely the declared count." Verified on this branch — declared 3, stream yields 8:

declared 3, bytes reaching sink: 8  [1,2,3,4,5,6,7,8]  | threw: EndOfStreamError

All eight bytes are committed to the sink; EndOfStreamError arrives after. Once a transport has stamped Content-Length: 3, those extra five bytes sit on the socket immediately after the body, where the peer parses them as the start of the next message — the classic request-smuggling shape. A thrown error does not recall bytes already written.

The short-stream direction has a related problem: the finally runs writer.close() before the delivered !== declared check, so a truncated body is closed cleanly — signalled to the sink as complete — and only then reported as an error. writer.abort() is the signal that actually tells the transport the message is broken.

Suggest bounding the write inside the loop (if (delivered + value.length > declared) → write only the remainder and fail, or fail immediately on overrun) and aborting rather than closing on any length mismatch, so no mis-framed body ever reaches the wire.

Comment thread packages/core/src/body/simple-bodies.ts Outdated
try {
if (this.#bytes.length > 0) await writer.write(this.#bytes);
} finally {
await writer.close();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finally { await writer.close() } discards the real failure. This pattern is in all five body implementations and it loses the cause every time.

When the sink rejects — the ordinary "connection died mid-upload" case — the finally calls close() on an already-errored writer, that rejects too, and a throwing finally replaces the in-flight exception. Verified against a sink whose write() throws SOCKET GONE:

body surfaced error
StringBody Cannot close a writable stream that is closed or errored
ByteArrayBody same
MultipartBody same
StreamBody same

The real cause is gone in every case — not chained, not suppressed, just replaced.

This is worse than a bad error message, because retry classification reads the cause chain. RETRY-2 defines the retryable set as "any throwable that is, or has anywhere in its cause chain, an I/O error or a timeout error." A genuine network failure that arrives as a TypeError about closing a stream will not match, so Phase 5a's retry stack will decline to retry a failure that is squarely retryable — and it will do so silently, which is the hardest kind of bug to find later.

The shape that preserves it:

let failure: unknown;
try { /* write */ } catch (e) { failure = e; throw e; }
finally {
  try { await writer.close(); }
  catch (closeError) { if (failure === undefined) throw closeError; }
}

Same fix at simple-bodies.ts:75, stream-body.ts:63, and multipart-body.ts:147. RECOV-12 spells out the general rule — a close error must never mask the primary, and should ride along as a suppressed/secondary error where the language supports it.

* re-reading the body (HTTP-44). Concurrent first callers share the single in-flight parse (HTTP-45).
*/
value(): Promise<T> {
this.#memoized ??= this.#parse(this.#response);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A parser that throws synchronously is never memoized, so the handler re-runs and re-reads the single-use body.

??= assigns the result of the right-hand side — if #parse throws before returning a promise, the assignment never happens and #memoized stays undefined. Verified: a parser that throws synchronously is invoked 3 times across 3 value() calls. HTTP-44 requires the outcome be memoized "without re-running the handler or re-reading the single-use body," and is explicit that both a null success and a thrown failure are covered.

This is easy to dismiss because the signature says => Promise<T>, but a non-async function returning a promise is perfectly ordinary, and so is validating an argument before the first await. The second call then re-reads a body whose bytes are already gone, so the failure mode is a confusing second error rather than the real one.

Wrapping the call so a synchronous throw becomes a rejected promise fixes it and costs nothing:

value(): Promise<T> {
  this.#memoized ??= (async () => this.#parse(this.#response))();
  return this.#memoized;
}

The async-rejection and null-success paths both memoize correctly today — memoizing the promise rather than the value is the right call and neatly sidesteps HTTP-44's null-success clause.

/** Non-consuming preview from the buffered copy (BODY-33). Null for no body. */
preview(charset = 'utf-8'): string | null {
if (this.#bodyBytes === undefined) return null;
return new TextDecoder(charset).decode(this.#bodyBytes);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preview() hardcodes UTF-8 while the media type it needs is sitting in #mediaType.

Verified: a 500 whose body is café in ISO-8859-1, with content-type: text/plain; charset=iso-8859-1 stored on the error, previews as caf\uFFFD. body() uses #mediaType; preview() ignores it.

HTTP-42 sets the rule for exactly this — text reads default to the charset declared in the media type, falling back to UTF-8 when absent or unknown. Reusing that resolution here would make the preview correct and keep one charset rule in the codebase rather than two.

Separately, new TextDecoder(charset) throws RangeError on an unrecognised label, so preview('bogus') throws out of a @public method on an error object — the one place a caller is least able to handle another exception. HTTP-42's "falling back to UTF-8 when ... the declared charset is unknown" is the behaviour to copy: resolve, fall back, never throw.

Comment thread packages/core/src/body/simple-bodies.ts Outdated
for (const v of value) {
if (typeof v === 'string') builder.add(key, v);
}
} else if (typeof value === 'string' || value === null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-string values are silently dropped from the form body.

Neither branch matches a number/boolean/Date, so the entry vanishes with no error. Verified: formUrlEncodedBody({count: 5, name: 'x'}) produces name=x — the count field is gone.

TypeScript callers are mostly protected by FormUrlEncodedInput, but this is a @public factory in a published package and types evaporate at runtime; a JavaScript consumer passing {page: 1} gets a silently incomplete request body and a puzzling server-side error. Same for the array branch at :109, which drops non-string elements one at a time.

Either coerce (String(value), which is what most form encoders do and what a caller passing a number expects) or reject with a named error. Silently discarding caller data is the one option that leaves no way to diagnose it — and this codebase is otherwise consistent about that, e.g. HTTP-4's "never silently substituting defaults".

*
* @public
*/
export function streamBody(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No @throws anywhere in the new public surface, which breaks the convention Phase 1 established.

Counting across the branch: 22 @throws tags across 9 http/ modules, 0 across all 11 body/ modules. CLAUDE.md is explicit — "Anything the barrel exports needs a TSDoc block with @public, plus @throws naming each catchable error class on operations that throw."

Several newly-promoted symbols throw:

  • streamBody(...) / StreamBody.writeToConsumedBodyError, EndOfStreamError
  • multipartBody(parts, boundary)MultipartBoundaryError
  • materialize(body) → whatever the wrapped writeTo raises
  • HttpStatusError.preview(charset)RangeError (see the separate note)

media-type.ts is the model to follow — it names the error type and enumerates the conditions, which is what makes the tag worth having rather than ceremony.

This one is cheap to fix and worth doing before the surface ships, since api-extractor has already baked these signatures into core.api.md and consumers will start writing catch blocks against them.

@OmarAlJarrah OmarAlJarrah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, focused on the two logging tees, Response, and — mostly — on whether the tests actually verify the requirements they name. Line coverage is 95%+, so I ran a mutation campaign instead: break a requirement in the source, run the suite, see if anything goes red.

Eleven MUST-level requirements have tests that genuinely catch a break. Deleting the CR/LF strip in quoteParam, skipping tap.clear(), mis-reporting contentLength, ignoring the media-type charset, raising the 1 MiB error cap to 1 GiB, treating every status as an error, reporting a multipart replayable when a part is not, copying past the tap's remaining room, dropping the staged overflow chunk, making the fits-regime non-repeatable, and returning a non-replayable body from materialize — every one of those goes red. That is a genuinely well-tested phase, and the mutation results are the evidence rather than the coverage number.

Two survived, both the same shape, detailed below. Everything else here is smaller.

Two corrections to my own hypotheses, since I would rather report the check than the guess:

  • I expected a BODY-28 violation — a cancel() failure on the fits path becoming a cached drain error and permanently blocking the captured body. It does not happen: by the time closeDelegate runs on that path the stream is already closed, so cancel() is a spec no-op and never reaches the failing algorithm. The captured body serves fine. No finding.
  • My first BODY-19 mutant (bypassing the tap.size < cap guard) survived, but it is an equivalent mutant — the room arithmetic below it already clamps, so the guard is redundant rather than untested. Mutating the clamp itself is caught by 2 tests. The tap cap is well covered.

BODY-24's exceeds-cap path also checks out end to end: with cap=2 over chunks [1,2,3,4]/[5,6] the consumer receives all six bytes via prefix + staged tail + live remainder, the preview stays at [1,2], and a second read() throws ConsumedBodyError.

});

describe('withResponseLogging lifecycle (BODY-27, 28)', () => {
test('close is idempotent and shared across the wrapper close and tail completion (BODY-27)', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test has no assertions, and the requirement it names survives having its guard deleted.

The body calls logged.close() twice and ends. There is no expect(), so the only thing it can detect is a thrown exception. Deleting if (state.closed) return; from closeDelegate leaves the entire suite green — verified by mutation.

It passes because the failure mode is not a throw. Cancelling an already-cancelled ReadableStream resolves quietly per the Streams spec, so a wrapper that cancels its delegate two, five, or fifty times looks exactly like one that cancels it once. But BODY-27's requirement is specifically counting: "MUST close the delegate at most once across all close paths ... because some transport streams throw on double-close." The whole point is the transports that are less forgiving than a spec-compliant ReadableStream — which is exactly what a spec-compliant ReadableStream cannot demonstrate.

What would catch it is a delegate that counts cancel() invocations, then asserting the count is 1 after exercising both close paths — the wrapper's own close() and the tail stream's completion, since BODY-27 requires they share one guard. Worth covering both, because the shared-guard half is the part a refactor is most likely to break.

response.test.ts already uses a cancelled flag a few tests along; this just needs the same treatment with a counter.

Comment thread packages/core/src/http/response.test.ts Outdated
});

describe('close (HTTP-41/BODY-15, HTTP-43)', () => {
test('is idempotent', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shape as the BODY-27 test, same result: no assertions, and deleting the guard keeps the suite green.

Two await response.close() calls, no expect(). Removing if (this.#closed) return; from Response.close() is caught by nothing.

BODY-15 and HTTP-43 both frame this as a counting property — close "MUST be idempotent" and the underlying resource is released at most once — and idempotence observable only as absence of a throw is not really being tested. Since cancel() on an already-cancelled stream resolves, the guard could vanish in a refactor and nothing would notice until a transport whose cancel is not re-entrant shows up in Phase 8.

The fix is small and the file already knows how: releases the connection even when the body was never read, a few lines below, builds a stream with a cancelled flag. Reuse that shape with a counter and assert it lands on 1 after two closes.

While you are in here — Response.close() sets #closed = true before awaiting cancel(). If cancel() rejects with anything that is not a TypeError, the response is marked closed, the error propagates, and a caller who retries gets a silent early return on a connection that was never released. Same pattern flagged on BufferedSink.close() in #31, so it is worth settling once and applying to both.

* Reads until EOF (fits regime) or until the cap is reached (exceeds regime, leaving the delegate open
* and the overflow chunk staged). BODY-26: a failure is cached, never allowed to truncate silently.
*
* BODY-25 note: the requirement's "zero bytes returned for a positive requested count" has no analog

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module and RetentionWindow now take opposite positions on the same question, each documented as deliberate.

Here a zero-length chunk is "a legal no-op, not an EOF signal" and the loop continues. In io/retention-window.ts:143 the same input raises SourceContractViolationError, citing IO-17's "never tolerated as EOF and not spun on". Verified: a stream yielding [] then [7] drains cleanly to [7] through withResponseLogging, while the equivalent through BufferedSource throws.

Both readings are defensible, and your reasoning here is sound — ReadableStreamDefaultReader.read() genuinely has no requested-count for "zero bytes for a positive requested count" to apply to. The problem is having both in one package, because the two layers meet: a response body flows through withResponseLogging in Phase 7 and through BufferedSource wherever the body layer reaches for it, so the same upstream would fail in one path and succeed in the other depending on which wrapper it passed through.

Worth picking one and having the other cite it. The tolerant reading looks more defensible for byte streams, which would mean relaxing the io/ side — with the caveat that IO-17's spin risk is real if a source only ever yields empty chunks, so the tolerant version probably wants a bound on consecutive empties rather than an unconditional continue.

state.tailConsumed = true;
return tailStream(state);
},
snapshot: () => state.captured.snapshot(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snapshot() never triggers a drain, so calling it before the first read() returns empty rather than the captured body. Verified: [] on a wrapper over a three-byte stream.

BODY-22 names the trigger set explicitly — the drain happens "lazily, on the first access (read/snapshot/exception query)" — so on a literal reading snapshot() should start it. BODY-26 then carves out the exception query ("surfaces the cached error ... without triggering a drain"), and error() correctly implements that, with a comment. snapshot() has no such carve-out in the spec and no note here.

I do not think the current behaviour is obviously wrong — for a logging tee, snapshotting before anything has been read is arguably a caller error, and draining from a preview accessor has its own surprise factor. But it is a deliberate divergence from a MUST's stated trigger list, and this project's discipline is that those get recorded rather than absorbed. Either drain from snapshot() to match BODY-22, or add a line saying why it deliberately does not, the way error() does.

snapshot(): Uint8Array {
return tap.snapshot();
},
materialize: async () => wrap(await materialize(inner)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

materialize() returns a new wrapper sharing the same tap buffer as the original, because wrap closes over tap from the factory scope rather than allocating a fresh one.

Verified: after const mat = await logged.materialize() and writing mat, both mat.snapshot() and logged.snapshot() return the same bytes. Two live wrappers, one tap.

BODY-21 asks that materialize "return a wrapper around the delegate's replayable form (preserving the tap cap)" — the cap, not the buffer. In practice the original is consumed by the materialization so it rarely matters, but Phase 7 is where it would bite: a retry loop holding the pre-materialization wrapper for its first-attempt preview finds those bytes silently rewritten by the second attempt, because tap.clear() at the start of every write (BODY-18) is clearing a buffer two objects believe they own. Confusing to debug, easy to prevent.

Giving wrap its own ByteQueue per invocation, with cap still captured from the factory, keeps BODY-21 and drops the aliasing.

@Wahbeh-Mohammad
Wahbeh-Mohammad force-pushed the 5-phase-3b-requestresponse-body-lifecycle branch from 10edc39 to 8a1a874 Compare August 25, 2026 19:04
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 765dade into 3-phase-3 Aug 25, 2026
2 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 5-phase-3b-requestresponse-body-lifecycle branch August 25, 2026 19:06
Wahbeh-Mohammad added a commit that referenced this pull request Aug 26, 2026
* feat(core): add request/response body lifecycle: Body model, materialize(), TypedResponse, HttpStatusError, logging tees (BODY-1..37, HTTP-36..52).

* fix(core): resolve body-lifecycle review findings (BODY-3..37, HTTP-26/39/42/43/44/51).

* test(core): cover withBodyWriter teardown paths (RECOV-12, RETRY-2).
Wahbeh-Mohammad added a commit that referenced this pull request Aug 26, 2026
…body lifecycle (#34)

* feat(core): add I/O contracts — ByteQueue, BufferedSource/Sink, view. TeeSink. (#31)

* feat(core): add I/O contracts — ByteQueue, BufferedSource/Sink, views, TeeSink.

* test(core): close per-file coverage gaps in invariant, io/errors, rejection helper.

* fix(core): resolve I/O contract review findings — copy semantics, lifecycle, and encoding symmetry (IO-1..IO-42).

* feat(core): request/response body lifecycle. (#32)

* feat(core): add request/response body lifecycle: Body model, materialize(), TypedResponse, HttpStatusError, logging tees (BODY-1..37, HTTP-36..52).

* fix(core): resolve body-lifecycle review findings (BODY-3..37, HTTP-26/39/42/43/44/51).

* test(core): cover withBodyWriter teardown paths (RECOV-12, RETRY-2).

* fix(core): phase 3 review — sink ownership, close guarantees, HTTP-2

Nine defects found reviewing the shipped io/ and body/ layers against the
phase 3a/3b plans. Seven are wire-correctness or public-API.

Sink ownership, three sites and one root cause. A WritableStream adapter
declaring write and close but no abort silently swallows the delegate's
abort — the default abort algorithm is a no-op — so withRequestLogging left
the caller's sink open, locked, and never told the message was broken. It
now forwards both teardown paths, and closes on behalf of a delegate that
resolves without doing so. StreamBody.writeTo passes preventCancel: true:
pipeTo's default cancels the caller's source when the sink fails, taking
ownership BODY-8 leaves with the caller, and disagreeing with the
declared-length path, which only releases its reader.

Close guarantees. Response.bytes/text and toHttpError acquire the body
reader inside the try. getReader() itself throws when an external consumer
holds the lock — which BODY-15 forbids assuming away — so the one failure
BODY-16's guarantee most needs to cover was the one that skipped the close
and held the connection.

Declared length vs bytes written. MultipartBody.writeTo verifies its total
against contentLength, refusing an overrunning chunk before it is written.
The shared framing routine keeps the framing honest but takes each part's
own length on trust, and MultipartPart.body is the public Body interface,
so a caller implementation could report one length and write another
(HTTP-51). Every Body variant is frozen at construction, so contentLength
cannot be reassigned after the fact (HTTP-1, XCUT-15).

Public API. Response regained the private constructor and createResponse
friend hook a rewrite had dropped, which had published a field-wise
constructor (HTTP-2). [Symbol.asyncDispose] is removed from Response and
LoggedResponseBody: it postdates engines.node ">=18.17", where the computed
key binds the method to the string "undefined", and its type reached this
package only through a dev-only global — so the published .d.ts did not
compile for a consumer on the lib this package itself declares. The API
report is back to zero (undocumented) members, from 62.

New blocking gate verify:consumer-types compiles a throwaway consumer
against the built .d.ts on the declared lib with types: [], which is the
gate whose absence let the asyncDispose defect clear every other one.
Verified to fail on the reintroduced defect and pass once reverted.

Also: assertCount single-sourced in io/limits.ts and applied to TeeSink,
the fourth size-taking surface, which had none (IO-3); BODY-25's zero-chunk
rule applied on the exceeds-cap tail path, not only the drain; an
over-reporting primitive source raises SourceContractViolationError instead
of surfacing as an exhausted stream (IO-17); http/charset.ts's decodeText
renamed decodeBodyText so it stops colliding with io/text-codec's
deliberately different one.

* docs: expand phase 3 open findings, correct checkpoint status

The phase 3b plan lists the 2026-07-25 checkpoint as a signed-off
prerequisite. It has no commit and every box is unchecked — but §5.1 landed
in bunfig.toml and half of §5.3 landed in errors.ts, which is exactly what
made the claim look true to a spot check. Records the measured status of all
twelve §5 items rather than the flat "it did not run".

Grows the phase 3b execution findings from two rows to seven. E1 and E2 gain
verified version numbers and measured blast radius; E3-E7 are new: §5.3
applied to 2 of 10 error leaves and stopped, §5.7 no isolated linker
configured, §5.9 no test:node script exists although the 3b plan's own gate
sequence calls it, §5.10 none of the eleven model files carries the #private
justification, §5.8 stale NFR-14 reason.

Resolves phase 4b's F1 to branch (b). Two of its premises were false: the
floor was never raised, and SuppressedError arrived in Node 24.0.0 with the
full Explicit Resource Management proposal rather than in the 18.18.0/20.4.0
symbols backport — so branch (a) means dropping Node 18, 20 and 22 outright.
esnext.disposable supplies Symbol.asyncDispose's type but not
SuppressedError's runtime, so E1's floor bump does not fix F1 and must not be
read as doing so, including by 5a, 6b and 6c.

Adds the phase 3b checklist, which was missing entirely, and records this
phase's own residuals separately from the checkpoint's — among them the
multipart boundary non-appearance limitation, which is documented rather than
partially checked because a StreamBody part's bytes do not exist until the
write.

* chore: date-prefix changeset filenames

`@changesets/write` names every changeset with a random `human-id`
(`dry-candles-unite.md`), and there is no config knob for it — the ID comes
from a hardcoded `humanId()` call, and `.changeset/config.json`'s schema has
no filename field. The names were already being hand-corrected after the fact.

Add `scripts/changeset.mjs`, wired as `bun run changeset`: it forwards every
argument to the CLI, then renames whatever changeset the run produced to
`YYYY-MM-DD-<slug>.md`, matching `docs/superpowers/{specs,plans}`. The slug is
prompted for and defaults to the changeset's own first sentence; a non-TTY
caller takes that default rather than hanging on a prompt nobody can answer.
Subcommands that create nothing (`version`, `status`, `publish`, `tag`, `pre`,
`init`) pass straight through.

Renaming after the fact is safe because nothing reads the filename back: the
CLI globs `.changeset/*.md` and takes every decision from the frontmatter. The
seven existing changesets are backfilled with the date of the commit that
added each one.

No CI gate — a changeset written by hand or by another tool is not checked.

* ci(test): add the Node-runtime conformance suite, close checkpoint §5.9

`bun test` runs the whole unit suite on Bun's runtime and proves nothing
about the runtime this SDK ships to. Audited before writing anything: 319 of
the 516 unit tests, across 21 of 43 files, exercise a runtime-divergent
surface — Web Streams, AbortSignal, async iteration, or ByteQueue's
Uint8Array handling — against exactly two assertions of Node coverage in
scripts/verify-node-floor.mjs, neither of which touched io/. The ci job
pinned no Node at all, so its three node-executed gates ran on an undeclared
runner default, and node-floor-conformance pinned 18.17.0 alone, leaving
current LTS unexercised against sdk-design-nodejs/09:52-54's "in addition to
current LTS".

Implements §5.9's own prescription rather than a substitute. bun test stays
the unit runner untouched — docs/knowledge/testing.md mandates bun:test
symbol imports, setSystemTime and --concurrent, so migrating to node:test
would be a styleguide deviation plus a whole-suite rewrite — and is now
scoped to packages/ via bunfig's [test] root so the two layers cannot blur.
Without that scoping bun test collected the new .mjs files too, which would
have run the Node-only layer on Bun and erased the distinction it exists to
draw.

Adds test/node-conformance/: 30 `node --test` cases over the BUILT artifact,
never src/. Public surface arrives through the @dexpace/core specifier, the
path a real consumer takes; io/ is @internal with no public subpath in
exports, so it is reached by direct dist/ file path. Seeded with composeSignal,
Phase 3a's byte-stream surface (chunk-straddling CRLF, slice views not
advancing the parent, reader-lock release on close, tee mirror-and-forward,
writeAll), and Phase 3b's body surface (reader-lock discipline on
bytes/text/close, pipeTo preventCancel ownership, multipart framing through
Web Crypto, toHttpError buffering).

scripts/verify-node-floor.mjs is retired and its two AbortSignal.any
assertions folded in as the suite's first cases, per §5.9:375's "rather than
keeping two parallel Node entry points". The CI job is renamed
node-conformance and is now a fail-fast:false matrix over
['18.17.0', 'lts/*']; lts/* resolves at run time so the LTS half cannot go
stale as LTS moves.

The 3b plan's Task 13 Step 3 called `bun run test:node` when no such script
existed, so that gate sequence could not be executed as written; it is
corrected, along with the two blocking gates it had never listed.

NOTE: the CI job name changed. Branch protection requiring
`node-floor-conformance` must be updated to `node-conformance`.

* chore: add an empty changeset for the Node conformance suite

`changeset --empty`, deliberately, rather than no changeset at all. Commit
e3d0b18 touched zero files under packages/ — everything in it is repository
infrastructure that ships to nobody — so there is nothing for @dexpace/core
to bump, and a patch would put a changelog line in front of consumers that
means nothing to them.

The empty changeset is what distinguishes "this change needs no release"
from "somebody forgot a changeset". `changeset status` is unchanged by it:
still one minor for @dexpace/core, from the five existing non-empty
changesets.

Created through scripts/changeset.mjs so the filename follows the repo's
YYYY-MM-DD-<slug> convention; --empty produces no summary to derive a slug
from, so the wrapper fell back to a generic name and it was renamed using
the wrapper's own toSlug logic once the summary was written.

* fix(core): raise the Node floor to 20.3, close two floor defects

The PR's node-conformance job failed on the pinned floor and passed on
`lts/*`. Two unrelated defects, both invisible to `bun test` by construction.

`MultipartBody` generates its boundary from `crypto.getRandomValues` — a bare
global — while `engines.node` declared `">=18.17"`. Node exposes
`globalThis.crypto` unflagged only from 19.0.0, and never to an ES module on
any 18.x release: verified on 18.17.0 and 18.20.8, where `typeof
globalThis.crypto` is `undefined` in `.mjs` and an object in CJS. So every
`multipartBody(...)` call threw `ReferenceError: crypto is not defined` on the
version the package promised, and a CommonJS probe would have reported that
floor as satisfied. Bun supplies the global, which is why 516 unit tests never
saw it and E5's suite caught it the first time it ran the built artifact on the
pinned floor.

The floor moves to `>=20.3`, chosen over the two options that keep Node 18. A
`node:crypto` fallback puts a Node-only specifier in a package documented as
running on browsers, Deno, Bun and Workers, and cannot be reached synchronously
from the constructor that needs it. A non-crypto RNG silently downgrades the
unguessable-boundary mitigation HTTP-51 leans on against multipart injection,
on exactly the runtime CI pins. Node 18 went EOL in April 2025, so no supported
runtime is dropped.

20.3 and not 20.0: `AbortSignal.any()` — `composeSignal`'s own floor-defining
call, backported to 18.17.0 — reached the 20.x line only in 20.3.0, confirmed
by running the suite against a pinned 20.0.0. `lib`/`target` move to ES2023 so
`verify:runtime-floor` stays consistent; its `es2023` row is amended to `>=20.3`
with the built-ins, not the syntax, named as the reason the floor sits above the
language level's own minimum. The CI matrix pin moves 18.17.0 -> 20.3.0, and
`seams.test.mjs` gains a case asserting `globalThis.crypto.getRandomValues` is a
function *in ESM* — verified to fail on 18.17.0 and pass on 20.3.0 — so this
cannot regress silently.

Second defect: `seams.test.mjs` awaited an `AbortSignal.timeout()` abort with
nothing else scheduled. That timer is unref'd on every Node version by design,
so the loop drained before it fired and 18.17.0's runner cancelled the rest of
the file (`Promise resolution is still pending but the event loop has already
resolved`). Newer runners hold the loop open through handles of their own, which
is the whole reason it passed on LTS. It now holds a ref'd deadline that both
keeps the loop alive and fails the case if the abort never arrives.

`sdk-design-nodejs/02`'s runtime line claimed Node >=18.17 supplies
`globalThis.crypto.subtle`; corrected. Recorded as roadmap finding E8, which
also renumbers E1: Symbol.dispose/asyncDispose reached the 20.x line in 20.4.0,
so §5.4's bump now reads `>=20.3` -> `>=20.4`. The symbol is still declared
nowhere.

Gates: typecheck, lint, build, bun test (516), api, lint:publish,
verify:dual-consumption, verify:consumer-types, verify:seam-1,
verify:runtime-floor, audit, and test:node on both 20.3.0 and current Node.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants