Skip to content

Domain-model input validation: prototype keys, invalid Date, fractional timeoutMs, lone surrogates, frozen getAll (#76) - #92

Merged
Wahbeh-Mohammad merged 10 commits into
audit/remediation-67from
audit/67/76-domain-model-validation
Sep 5, 2026
Merged

Domain-model input validation: prototype keys, invalid Date, fractional timeoutMs, lone surrogates, frozen getAll (#76)#92
Wahbeh-Mohammad merged 10 commits into
audit/remediation-67from
audit/67/76-domain-model-validation

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Closes #76. Milestone 4 of the audit-#67 remediation umbrella; implements ledger decision D14
(docs/audit-67-decisions.md:311-333). Base audit/remediation-67.

Every bullet of the issue is one commit, written test-first. No new error class, no changeset (D1),
no docs/deviations.md row (outside #76's partition — see "For the supervisor" below).

What changed

Bullet Fix Files
Prototype keys satisfy a path placeholder Object.hasOwn(pathParams, name) gates the read, so {constructor} against {} is OperationAssemblyError instead of /users/function%20Object%28%29… seams/operation.ts
Invalid Date goes on the wire ifModifiedSince / ifUnmodifiedSince reject Number.isNaN(date.getTime()) with RequestConditionsValidationError, naming the header http/request-conditions.ts
Fractional timeoutMs throws at send the setter requires an integer in 1 .. 2**32 - 1AbortSignal.timeout()'s own range; the TSDoc paragraph arguing the other way is rewritten and the test that pinned 1.5 is flipped. composeSignal gains its first @throws http/request-options.ts, seams/transport.ts
Lone surrogate throws URIError rfc3986.ts single-sources the rule as hasLoneSurrogate() / toWellFormed(); QueryParamsBuilder.add rejects with UrlConstructionError, substitutePathParams with OperationAssemblyError, and QueryParams.parse substitutes U+FFFD because HTTP-31 forbids throwing http/rfc3986.ts, http/query-params.ts, seams/operation.ts
getAll on an absent name returns an unfrozen array one frozen EMPTY_VALUE_LIST per model http/headers.ts, http/query-params.ts
Headers.equals has no direct test nine direct cases http/headers.test.ts
TeeSink accepts a fractional tapLimit Number.isInteger(tapLimit) || tapLimit === Infinity, same InvariantViolation the constructor already threw io/tee-sink.ts

Two decisions worth reading

QueryParams.parse is lenient, add is strict. D14 says to reject an unpaired surrogate at the
call site that supplied it. parse is a call site that MUST NOT throw — HTTP-31 is MUST-level
("lenient … falls back to raw text rather than throwing"), and parse feeds the strict add. So the
model draws the same outbound/inbound split Headers already draws for HTTP-18 against HTTP-19:
add throws, parse substitutes U+FFFD. Replacement is what the platform does with the same input —
new URL('https://x/?a=\uD800').search is ?a=%EF%BF%BD, measured 2026-09-05.

Deviation from D14's letter: /\p{Surrogate}/u, not String.prototype.isWellFormed(). The method
exists on the engines.node >= 20.3 runtime but is ES2024, and this repo's declared lib is ES2023
(tsconfig.base.json:5-11), so it does not type-check; raising lib is a repo-wide change outside the
partition. In u mode a well-formed surrogate pair is one non-surrogate code point, so the pattern
matches lone surrogates only — verified equivalent to isWellFormed()/toWellFormed().

Tests added

File Rows
packages/core/src/seams/operation.test.ts 5 prototype-key names (constructor, toString, hasOwnProperty, valueOf, __proto__) against {}; the error names the placeholder; an own property named like a prototype member still resolves; a null-prototype pathParams still resolves; 3 lone-surrogate values; a surrogate pair encodes; the query projection from both the builder and parse; a property — no URIError escapes buildRequest
packages/core/src/http/request-conditions.test.ts invalid Date on both setters, 2 spellings each; the message names the header; nothing invalid can reach applyTo
packages/core/src/http/request-options.test.ts the flipped fractional case; above 2**32 - 1; the inclusive ceiling; a property — every admitted timeout is an integer in range, everything else is RequestOptionsValidationError
packages/core/src/seams/transport.test.ts every timeout the builder admits composes; every one it rejects never reaches composeSignal
packages/core/src/http/query-params.test.ts 4 lone-surrogate add() rows; a surrogate pair; parse substitutes on value and on name; 2 properties — nothing escapes encode()/equals(), nothing escapes parse(); getAll frozen on both paths
packages/core/src/http/headers.test.ts getAll frozen on both paths and shared on the miss; 9 direct Headers.equals cases
packages/core/src/io/tee-sink.test.ts 5 rejected tapLimits, 4 accepted, the message names tapLimit, the Infinity default
tests/node-conformance/seams.test.mjs 2 rows — see below

One file outside the literal partition, and why

tests/node-conformance/seams.test.mjs. AbortSignal.timeout() is runtime-divergent, measured
2026-09-05: Node raises RangeError for 1.5, for 2**32 and for -1; Bun accepts 1.5 and
2**32
and raises TypeError for -1. So bun test cannot assert either half of the behaviour
this 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 owns composeSignal;
the README needs no edit (it says to ls the directory), and neither #73 nor #77 owns it.

Deviation rows added

Nonedocs/deviations.md is explicitly not in #76's partition. Two candidates are listed below
for the supervisor to place under D0.

Deferred — release machinery

Skipped under D1:

  • patch changeset for @dexpace/core. Five observable behaviour changes, all rejections that were
    previously accepted-then-failed-later or accepted-and-shipped-wrong:
    • buildRequest throws OperationAssemblyError for a {name} placeholder resolved off the
      prototype chain (previously assembled a URL from Object.prototype's member).
    • RequestConditionsBuilder.ifModifiedSince / ifUnmodifiedSince throw
      RequestConditionsValidationError for an invalid Date (previously emitted
      If-Modified-Since: Invalid Date).
    • RequestOptionsBuilder.timeoutMs throws for a non-integer and for anything above 2**32 - 1
      (previously accepted; 1.5 was pinned as accepted by a test, so this is the one row a
      consumer could be relying on).
    • QueryParamsBuilder.add throws UrlConstructionError for an unpaired surrogate;
      QueryParams.parse substitutes U+FFFD for one (previously stored it and threw URIError from
      encode()).
    • Headers.getAll / QueryParams.getAll return a frozen shared list for an absent name (previously
      a fresh mutable []).
      TeeSink is @internal, so its tapLimit change is not consumer-visible.
  • .d.ts prose changed on buildRequest, OperationAssemblyError, OperationDescriptor.pathParams,
    composeSignal, RequestOptions.timeoutMs, RequestOptionsBuilder.timeoutMs, QueryParams,
    QueryParams.parse, QueryParamsBuilder.add, Headers.getAll, QueryParams.getAll,
    RequestConditions, RequestConditionsBuilder.ifModifiedSince / ifUnmodifiedSince.
  • No docs/first-release.md edit, though timeoutMs(1.5) is exactly its "free before the first
    version bump" class.

packages/core/etc/core.api.md regenerated byte-identical: api-extractor records signatures and
release tags, not @throws prose, and no signature changed. bun run api passes over all nine.

For the supervisor — outside #76's partition, reported not edited (contract item 4)

  1. docs/sdk-documentation/http.md:113-114 is now false. It says "A fractional timeoutMs is
    accepted — a timeout is a duration, not a count." Suggested replacement: "timeoutMs rejects zero,
    negatives, Infinity, NaN, a fractional value and anything above 2**32 - 1 (HTTP-35) — the
    range is AbortSignal.timeout()'s, the only one a transport can honor."
  2. packages/core/src/http/errors.ts has two stale @public TSDoc blocks.
    RequestOptionsValidationError still reads "a non-null timeout that is zero or negative, or a
    negative max-retries" — already incomplete before this PR (non-finite, fractional maxRetries) and
    more so now. UrlConstructionError reads "when a request URL is malformed or not absolute" and is
    now also the class QueryParamsBuilder.add throws for an unpaired surrogate, per D14's instruction
    to reuse it rather than add a class.
  3. The one URIError that still escapes core is in pagination/query-splice.tsspliceQueryParam
    and readQueryParam, both @internal, reached from pagination/strategies.ts:44,79,85. The cursor
    value there is server-supplied through PageInfo, so a server can make a paginator throw a bare
    URIError outside the DexpaceError tree. D14 says to report the path rather than add a second
    mechanism; the fix belongs to Serde and pagination: deserializeFrom abort, paginator leak on malformed PageInfo, tristate null cast, SSE double-reported release failure #79 (serde and pagination). Reproduction:
    spliceQueryParam(new URL('https://h/?a=1'), 'cursor', '\uD800').
  4. defaultTimeoutMs is unvalidated on both transports (fetch-transport.ts:73,205,215,
    undici-transport.ts:95,401,419). It bypasses RequestOptionsBuilder and is now the only remaining
    way an out-of-range delay reaches AbortSignal.timeout(). Documented in composeSignal's @throws;
    the fix belongs to undici transport: native header rejections, fileBody short-write detection, SOCKS proxy type #81 / fetch transport: permanent-error classification; both transports: 204/304/HEAD rows, CONTROL_BYTE regex, producer-failure race #82 or a transport pass.
  5. EMPTY_VALUE_LIST is declared twice, once in headers.ts and once in query-params.ts.
    http/builder.ts is the single home and is outside Domain-model input validation: prototype keys, invalid Date, fractional timeoutMs, lone surrogates, frozen getAll #76's partition; consolidating it is one line.
  6. Two candidate deviations.md rows under "Deviations recorded outside a phase", if the supervisor
    wants them:
    • HTTP-35 read wider than its letter. The rule is "MUST reject a non-null timeout that is zero or
      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 is
      strictly stricter, and the same widening already shipped unledgered for Infinity/NaN.
    • HTTP-31 satisfied by substitution rather than by raw-text fallback. The rule names malformed
      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) and
      packages/core/src/http/rfc3986.ts (toWellFormed).

…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.
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 36c3d04 into audit/remediation-67 Sep 5, 2026
1 check passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the audit/67/76-domain-model-validation branch September 7, 2026 18:42
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.

1 participant