Domain-model input validation: prototype keys, invalid Date, fractional timeoutMs, lone surrogates, frozen getAll (#76) - #92
Merged
Wahbeh-Mohammad merged 10 commits intoSep 5, 2026
Conversation
…EAM-27)
`substitutePathParams` read `pathParams?.[name]`, which walks the prototype chain. A
`{constructor}` placeholder against `{}` therefore resolved to `Object.prototype.constructor`,
stringified, and shipped
`/users/function%20Object%28%29%20%7B%20%5Bnative%20code%5D%20%7D` as a path segment instead of
failing assembly; `{toString}`, `{hasOwnProperty}`, `{valueOf}` and `{__proto__}` behaved the same
way. SEAM-27 requires every placeholder to have a *supplied* value, and a name the caller never
supplied is missing whatever `Object.prototype` happens to carry.
`Object.hasOwn(pathParams, name)` now gates the read, so an inherited member is absent and an own
property named like one is still honored. A null-prototype `pathParams` keeps working.
Found by: audit #67 / #76.
…(HTTP-50) `toRfc1123` is `date.toUTCString()`, which is total: a NaN time value renders the literal string `Invalid Date` instead of throwing. That string is HTAB-free printable ASCII, so HTTP-18's outbound header grammar accepts it and `If-Modified-Since: Invalid Date` reached the wire — a header no server can evaluate, produced by a caller mistake made several frames earlier. `ifModifiedSince` and `ifUnmodifiedSince` now reject `Number.isNaN(date.getTime())` with `RequestConditionsValidationError`, naming the header in the message. HTTP-50's "emit RFC 1123 dates" is not satisfiable from a NaN instant, so the setter that was handed the bad `Date` is where it fails. Found by: audit #67 / #76.
…nge (HTTP-35) `RequestOptionsBuilder.timeoutMs` accepted a fractional value on the argument that "a timeout is a duration and a fractional millisecond is meaningful", and accepted anything finite above `2**32 - 1`. Nothing downstream can express either. The only consumer of the field is `composeSignal`, which hands it to `AbortSignal.timeout()` — so `timeoutMs(1.5)` failed one seam away from the call site that supplied it, inside the transport, as an unwrapped platform error. HTTP-35 puts the range check at the setter; the range that setter must check is the one a transport can honor. The setter now requires an integer in `1 .. 2**32 - 1`, and the TSDoc paragraph arguing the other way is rewritten. The test that pinned `timeoutMs(1.5)` as accepted is flipped, and a property states the invariant: every admitted value is inside the range, everything else raises `RequestOptionsValidationError`. `composeSignal` gains the `@throws` it never had, and it is deliberately not a clamp — a value reaching it out of range now comes only from a transport's own unvalidated `defaultTimeoutMs` construction option. Runtime divergence, measured 2026-09-05: `AbortSignal.timeout(1.5)` and `AbortSignal.timeout(2**32)` raise `RangeError` on Node and are ACCEPTED on Bun, and a negative delay is `RangeError` on Node against `TypeError` on Bun. `bun test` therefore cannot assert either half, so the Node case goes to `tests/node-conformance/seams.test.mjs` per CLAUDE.md's membership rule — the file already owns `composeSignal`, and it is outside the letter of #76's partition for that reason. Found by: audit #67 / #76.
…, HTTP-31, SEAM-27)
`encodeRfc3986Component` is `encodeURIComponent`, which throws a bare `URIError: URI malformed` on
a string with no UTF-8 form. That escaped the `DexpaceError` tree entirely, and it escaped from the
wrong place: `QueryParams.encode()` and `QueryParams.equals()` document no throw at all, and
`buildRequest`'s `@throws` list did not name it either. The `add()` that accepted the value never
complained.
`rfc3986.ts` now single-sources the rule as `hasLoneSurrogate()` (strict) and `toWellFormed()`
(lenient), and each call site uses the one its own contract allows:
- `QueryParamsBuilder.add` rejects a non-well-formed name or value with `UrlConstructionError` —
the class D14 named, since the builder threw none of its own.
- `substitutePathParams` rejects one with `OperationAssemblyError`, naming the parameter.
- `QueryParams.parse` substitutes U+FFFD instead. HTTP-31 is MUST-level that parsing never throws,
so the strict path cannot be the one `parse` uses on text it did not choose; this is the
outbound/inbound split `Headers` already draws for HTTP-18 against HTTP-19. Replacement is what
the platform's own serializer does — `new URL('https://x/?a=\uD800').search` is `?a=%EF%BF%BD`.
A well-formed surrogate pair is ordinary text and stays accepted; the check is "no LONE surrogate",
not "no astral character".
Deviation from D14's letter: `String.prototype.isWellFormed()` is ES2024 and does not type-check on
this repo's declared `lib` (`tsconfig.base.json` pins `ES2023`), though the `engines.node >= 20.3`
runtime has it. `/\p{Surrogate}/u` is exact — in `u` mode a well-formed pair is one non-surrogate
code point — and verified equivalent to `isWellFormed()`/`toWellFormed()` on the cases tested.
Two properties pin the closure: no `URIError` escapes `encode()`, `equals()` or `buildRequest` for
any surrogate-bearing input, and `parse()` never throws for any.
Found by: audit #67 / #76.
…HTTP-5) `Headers.getAll` and `QueryParams.getAll` both promise "a read-only, frozen list of values — empty when the name is absent" and both returned a fresh, unfrozen `[]` on a miss. A caller who pushed into it saw the push succeed, which is the mutable-collection surface HTTP-5 exists to close, and it was a fresh allocation on every miss besides. One frozen `EMPTY_VALUE_LIST` per model — sharing an instance is safe precisely because it is frozen. Declared in each of the two files rather than once in `http/builder.ts`: that would be the single home, but it is outside #76's file partition, and a frozen empty array cannot drift. Consolidating it is a one-line follow-up for whoever owns that file next. Tests assert the frozen-ness of both the absent-name list and, for the first time, the present-name list: `build()` freezes each value list and `getAll` hands back that same reference, so nothing else in the suite would notice if that freeze were dropped. Also closes the other `headers.ts` gap the same audit found, since it is the same file and the same kind of missing assertion: `Headers.equals` had no direct test and was exercised only through `Request.equals`. Nine cases now cover name folding, value case-sensitivity, distinct-name order (irrelevant), value order under one name (relevant), strict subsets in both directions, disjoint names at equal name count — the case that proves the per-name lookup runs rather than the length pre-check — differing value counts, and two empty instances. No behaviour change from these: the implementation was already correct on all nine. Found by: audit #67 / #76.
…r (IO-3, IO-26) The constructor checked `tapLimit >= 0` only, so `new TeeSink(primary, 2.5)` was accepted and the fault was deferred to the first write: `#mirror` computes `room = tapLimit - tap.size`, hands it to `ByteQueue.copyTo`, and `assertCount` rejects it as `count must be a non-negative integer, got 2.5`. That names the wrong parameter, fires at the wrong call, and does so on a path that has already taken bytes from the caller. A tap cap is a byte count, so IO-3's integrality rule for `count` is the same rule. `Infinity` is admitted explicitly — it is not an integer and it is the documented unbounded default (IO-26). The error stays `InvariantViolation`, the class this constructor already threw for a negative limit; only its message changed, to name `tapLimit`. Found by: audit #67 / #76.
Round 2 of #76 widens the partition to cover this file. The frozen empty list was declared twice — once in `headers.ts`, once in `query-params.ts` — because `builder.ts` was outside the original partition. It is one constant now, in the module both models already import their shared construction helpers from. No behaviour change: `verify:import-cycles` stays clean, and the same `getAll` tests cover it. Found by: audit #67 / #76.
Both blocks shipped in the `.d.ts` and both were false, one of them before this run started. `RequestOptionsValidationError` read "a non-null timeout that is zero or negative, or a negative max-retries" — HTTP-35's own wording, and already incomplete: `Infinity`, `NaN` and a fractional `maxRetries` were rejected too, and #76 added a non-integer and an out-of-ceiling timeout. It now states both full ranges and why they are the full ranges. `UrlConstructionError` read "when a request URL is malformed or not absolute" and named one of the three throw sites. It is also raised for a malformed, non-absolute or fragment-bearing base URL in `buildRequest`, and — per D14, which chose reuse over a new class — for a query-parameter name or value carrying an unpaired surrogate. That third case is the one worth documenting: it sets no `cause` (nothing was caught), and `QueryParams.parse` deliberately does not raise it. Backticked prose throughout; no `{@link}` into an inherited member. `bun run api` clean over all nine reports; `core.api.md` unchanged, since api-extractor records signatures rather than prose. Found by: audit #67 / #76.
"A fractional `timeoutMs` is accepted — a timeout is a duration, not a count" was true when it was written and is false as of this branch: the setter now requires an integer in `1 .. 2**32 - 1`, `AbortSignal.timeout()`'s own range and the only one a transport can honor. Replaced with the range as it stands and the reason for it. `bun run build && node .claude/skills/housekeeping/check-fences.mjs`: 47 fences from 21 files, PASS. Found by: audit #67 / #76.
Both are readings this branch takes that depart from a requirement's literal text, appended at the end of "Deviations recorded outside a phase" as D0 prescribes. Round 2 widens #76's partition to cover this file; #73 appends to the same tail and the supervisor merges that seam. 1. **HTTP-35's timeout check is read as the FULL range `AbortSignal.timeout()` accepts.** The requirement names zero and negative; the port also rejects non-finite (shipped unledgered before this audit), non-integer and `> 2**32 - 1`. Stricter than the letter, and deliberately: the field has exactly one consumer, so a value the setter admits and that consumer refuses is HTTP-35's own failure mode with the seam moved. The row records the measured two-runtime divergence that makes deferring the check to the platform unsound. 2. **HTTP-31's "falls back to raw text" is satisfied for an unpaired surrogate by substituting U+FFFD.** The prescribed fallback is unavailable for that input — the raw text has no UTF-8 form, so keeping it only defers the `URIError` from `parse` into `encode()`. The row records the repair, the `Headers` HTTP-18/HTTP-19 precedent it follows, and the two rejected alternatives. Both cells carry `file:line` evidence, verified mechanically: every path resolves, every line number is in range and holds what the row says it holds, and every row in the table has five cells. Found by: audit #67 / #76.
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #76. Milestone 4 of the audit-#67 remediation umbrella; implements ledger decision D14
(
docs/audit-67-decisions.md:311-333). Baseaudit/remediation-67.Every bullet of the issue is one commit, written test-first. No new error class, no changeset (D1),
no
docs/deviations.mdrow (outside #76's partition — see "For the supervisor" below).What changed
Object.hasOwn(pathParams, name)gates the read, so{constructor}against{}isOperationAssemblyErrorinstead of/users/function%20Object%28%29…seams/operation.tsDategoes on the wireifModifiedSince/ifUnmodifiedSincerejectNumber.isNaN(date.getTime())withRequestConditionsValidationError, naming the headerhttp/request-conditions.tstimeoutMsthrows at send1 .. 2**32 - 1—AbortSignal.timeout()'s own range; the TSDoc paragraph arguing the other way is rewritten and the test that pinned1.5is flipped.composeSignalgains its first@throwshttp/request-options.ts,seams/transport.tsURIErrorrfc3986.tssingle-sources the rule ashasLoneSurrogate()/toWellFormed();QueryParamsBuilder.addrejects withUrlConstructionError,substitutePathParamswithOperationAssemblyError, andQueryParams.parsesubstitutes U+FFFD because HTTP-31 forbids throwinghttp/rfc3986.ts,http/query-params.ts,seams/operation.tsgetAllon an absent name returns an unfrozen arrayEMPTY_VALUE_LISTper modelhttp/headers.ts,http/query-params.tsHeaders.equalshas no direct testhttp/headers.test.tsTeeSinkaccepts a fractionaltapLimitNumber.isInteger(tapLimit) || tapLimit === Infinity, sameInvariantViolationthe constructor already threwio/tee-sink.tsTwo decisions worth reading
QueryParams.parseis lenient,addis strict. D14 says to reject an unpaired surrogate at thecall site that supplied it.
parseis a call site that MUST NOT throw — HTTP-31 is MUST-level("lenient … falls back to raw text rather than throwing"), and
parsefeeds the strictadd. So themodel draws the same outbound/inbound split
Headersalready draws for HTTP-18 against HTTP-19:addthrows,parsesubstitutes U+FFFD. Replacement is what the platform does with the same input —new URL('https://x/?a=\uD800').searchis?a=%EF%BF%BD, measured 2026-09-05.Deviation from D14's letter:
/\p{Surrogate}/u, notString.prototype.isWellFormed(). The methodexists on the
engines.node >= 20.3runtime but is ES2024, and this repo's declaredlibisES2023(
tsconfig.base.json:5-11), so it does not type-check; raisinglibis a repo-wide change outside thepartition. In
umode a well-formed surrogate pair is one non-surrogate code point, so the patternmatches lone surrogates only — verified equivalent to
isWellFormed()/toWellFormed().Tests added
packages/core/src/seams/operation.test.tsconstructor,toString,hasOwnProperty,valueOf,__proto__) against{}; the error names the placeholder; an own property named like a prototype member still resolves; a null-prototypepathParamsstill resolves; 3 lone-surrogate values; a surrogate pair encodes; the query projection from both the builder andparse; a property — noURIErrorescapesbuildRequestpackages/core/src/http/request-conditions.test.tsDateon both setters, 2 spellings each; the message names the header; nothing invalid can reachapplyTopackages/core/src/http/request-options.test.ts2**32 - 1; the inclusive ceiling; a property — every admitted timeout is an integer in range, everything else isRequestOptionsValidationErrorpackages/core/src/seams/transport.test.tscomposeSignalpackages/core/src/http/query-params.test.tsadd()rows; a surrogate pair;parsesubstitutes on value and on name; 2 properties — nothing escapesencode()/equals(), nothing escapesparse();getAllfrozen on both pathspackages/core/src/http/headers.test.tsgetAllfrozen on both paths and shared on the miss; 9 directHeaders.equalscasespackages/core/src/io/tee-sink.test.tstapLimits, 4 accepted, the message namestapLimit, theInfinitydefaulttests/node-conformance/seams.test.mjsOne file outside the literal partition, and why
tests/node-conformance/seams.test.mjs.AbortSignal.timeout()is runtime-divergent, measured2026-09-05: Node raises
RangeErrorfor1.5, for2**32and for-1; Bun accepts1.5and2**32and raisesTypeErrorfor-1. Sobun testcannot assert either half of the behaviourthis bullet is about, and CLAUDE.md's membership rule ("a phase that touches a runtime-divergent
surface adds a case there, not only to
bun run test") applies. The file already ownscomposeSignal;the README needs no edit (it says to
lsthe directory), and neither #73 nor #77 owns it.Deviation rows added
None —
docs/deviations.mdis explicitly not in #76's partition. Two candidates are listed belowfor the supervisor to place under D0.
Deferred — release machinery
Skipped under D1:
@dexpace/core. Five observable behaviour changes, all rejections that werepreviously accepted-then-failed-later or accepted-and-shipped-wrong:
buildRequestthrowsOperationAssemblyErrorfor a{name}placeholder resolved off theprototype chain (previously assembled a URL from
Object.prototype's member).RequestConditionsBuilder.ifModifiedSince/ifUnmodifiedSincethrowRequestConditionsValidationErrorfor an invalidDate(previously emittedIf-Modified-Since: Invalid Date).RequestOptionsBuilder.timeoutMsthrows for a non-integer and for anything above2**32 - 1(previously accepted;
1.5was pinned as accepted by a test, so this is the one row aconsumer could be relying on).
QueryParamsBuilder.addthrowsUrlConstructionErrorfor an unpaired surrogate;QueryParams.parsesubstitutes U+FFFD for one (previously stored it and threwURIErrorfromencode()).Headers.getAll/QueryParams.getAllreturn a frozen shared list for an absent name (previouslya fresh mutable
[]).TeeSinkis@internal, so itstapLimitchange is not consumer-visible..d.tsprose changed onbuildRequest,OperationAssemblyError,OperationDescriptor.pathParams,composeSignal,RequestOptions.timeoutMs,RequestOptionsBuilder.timeoutMs,QueryParams,QueryParams.parse,QueryParamsBuilder.add,Headers.getAll,QueryParams.getAll,RequestConditions,RequestConditionsBuilder.ifModifiedSince/ifUnmodifiedSince.docs/first-release.mdedit, thoughtimeoutMs(1.5)is exactly its "free before the firstversion bump" class.
packages/core/etc/core.api.mdregenerated byte-identical:api-extractorrecords signatures andrelease tags, not
@throwsprose, and no signature changed.bun run apipasses over all nine.For the supervisor — outside #76's partition, reported not edited (contract item 4)
docs/sdk-documentation/http.md:113-114is now false. It says "A fractionaltimeoutMsisaccepted — a timeout is a duration, not a count." Suggested replacement: "
timeoutMsrejects zero,negatives,
Infinity,NaN, a fractional value and anything above2**32 - 1(HTTP-35) — therange is
AbortSignal.timeout()'s, the only one a transport can honor."packages/core/src/http/errors.tshas two stale@publicTSDoc blocks.RequestOptionsValidationErrorstill reads "a non-null timeout that is zero or negative, or anegative max-retries" — already incomplete before this PR (non-finite, fractional
maxRetries) andmore so now.
UrlConstructionErrorreads "when a request URL is malformed or not absolute" and isnow also the class
QueryParamsBuilder.addthrows for an unpaired surrogate, per D14's instructionto reuse it rather than add a class.
URIErrorthat still escapes core is inpagination/query-splice.ts—spliceQueryParamand
readQueryParam, both@internal, reached frompagination/strategies.ts:44,79,85. The cursorvalue there is server-supplied through
PageInfo, so a server can make a paginator throw a bareURIErroroutside theDexpaceErrortree. D14 says to report the path rather than add a secondmechanism; the fix belongs to Serde and pagination:
deserializeFromabort, paginator leak on malformedPageInfo,tristatenull cast, SSE double-reported release failure #79 (serde and pagination). Reproduction:spliceQueryParam(new URL('https://h/?a=1'), 'cursor', '\uD800').defaultTimeoutMsis unvalidated on both transports (fetch-transport.ts:73,205,215,undici-transport.ts:95,401,419). It bypassesRequestOptionsBuilderand is now the only remainingway an out-of-range delay reaches
AbortSignal.timeout(). Documented incomposeSignal's@throws;the fix belongs to undici transport: native header rejections,
fileBodyshort-write detection, SOCKS proxy type #81 / fetch transport: permanent-error classification; both transports: 204/304/HEAD rows,CONTROL_BYTEregex, producer-failure race #82 or a transport pass.EMPTY_VALUE_LISTis declared twice, once inheaders.tsand once inquery-params.ts.http/builder.tsis the single home and is outside Domain-model input validation: prototype keys, invalidDate, fractionaltimeoutMs, lone surrogates, frozengetAll#76's partition; consolidating it is one line.deviations.mdrows under "Deviations recorded outside a phase", if the supervisorwants them:
negative"; the port also rejects non-finite (pre-existing), non-integer and
> 2**32 - 1(this PR).Evidence
packages/core/src/http/request-options.ts:184-196. Arguably not a deviation — it isstrictly stricter, and the same widening already shipped unledgered for
Infinity/NaN.percent-encoding; an unpaired surrogate is repaired to U+FFFD instead, because the raw text is
unencodable. Evidence
packages/core/src/http/query-params.ts(parse) andpackages/core/src/http/rfc3986.ts(toWellFormed).