Skip to content
1 change: 1 addition & 0 deletions docs/deviations.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ frozen tree and is amended only deliberately, by hand. When §10 is next amended
| **`HTTP-35`'s timeout check is read as the FULL range `AbortSignal.timeout()` accepts, not the lower bound the requirement enumerates.** `HTTP-35` says the options builder "MUST reject a non-null timeout that is zero or negative". `RequestOptionsBuilder.timeoutMs` rejects three more classes: non-finite (shipped unledgered before this audit), non-integer, and anything above `2**32 - 1`. **Strictly stricter than the letter, and deliberately so.** The field has exactly one consumer — `composeSignal` hands it to `AbortSignal.timeout()` — so a value this setter admits and that function refuses is `HTTP-35`'s own failure mode with the seam moved: the error surfaces inside a transport, as an unwrapped platform `RangeError`, one frame away from the call that supplied it. The earlier reading accepted `1.5` and argued in TSDoc that "a timeout is a duration and a fractional millisecond is meaningful"; no consumer of the field can express one. **The range checked is Node's, and that is the point:** `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 (measured 2026-09-05), so leaving the check to the runtime would make an SDK-level contract depend on which runtime the caller happens to be on. *Rejected:* rounding with `Math.ceil` and clamping inside `composeSignal`, which hides the caller's mistake in the one place `HTTP-35` exists to surface it. `composeSignal` is documented as still able to raise, because a transport's own `defaultTimeoutMs` construction option bypasses this setter and is not validated by core — recorded for #81/#82, not fixed here | audit #67 / #76 | 2026-09-05 | `docs/product-spec/04-core-http-domain-model.md:48` is `HTTP-35`'s wording. `packages/core/src/http/request-options.ts:12` (`MAX_TIMEOUT_MS`) and `:204-214` (the check and the rewritten TSDoc paragraph); `packages/core/src/seams/transport.ts:86-92` is `composeSignal`'s new `@throws`, which states the two-runtime divergence rather than naming one error class. Pinned by "rejects a fractional timeout, which no transport deadline can honor" (`packages/core/src/http/request-options.test.ts:128`, the FLIPPED case — it pinned acceptance until this audit), "rejects a timeout above AbortSignal.timeout()'s ceiling of 2**32 - 1" (`:134`), "accepts the ceiling itself" (`:143`) and the `every accepted timeout is an integer in 1..2**32 - 1` property (`:157`); the Node half is `composeSignal timeout range on Node (HTTP-35)` in `tests/node-conformance/seams.test.mjs:105`, which cannot live in `bun test` because Bun accepts both rejected values | not yet in §10 |
| **`HTTP-31`'s "falls back to raw text rather than throwing" is satisfied for an unpaired surrogate by SUBSTITUTING U+FFFD, not by keeping the raw text.** `HTTP-31` (MUST) makes `QueryParams.parse` lenient and enumerates the lenient cases, ending with "malformed percent-encoding falling back to raw text rather than throwing". An unpaired surrogate is a fourth kind of malformed input the enumeration does not name, and the fallback it prescribes is not available for it: the raw text has no UTF-8 form, so keeping it produces a `QueryParams` whose `encode()` throws `URIError` — the throw merely deferred out of `parse` and into an accessor that documents no throw at all. **The port repairs instead.** `parse` runs `toWellFormed()` over each decoded name and value, so every instance it returns is encodable, which is what "parsing MUST invert encode" needs to mean. The strict half of the rule is unaffected and is where `#76` puts the rejection: `QueryParamsBuilder.add` throws `UrlConstructionError` for the same input, and `substitutePathParams` throws `OperationAssemblyError`. That asymmetry is not new to the query model — it is exactly the outbound/inbound split `Headers` already draws for `HTTP-18` against `HTTP-19`, applied to the one requirement pair that needs it here. Substitution matches the platform rather than inventing a policy: `new URL('https://x/?a=\uD800').search` is `?a=%EF%BF%BD` (measured 2026-09-05). *Rejected:* letting `parse` throw the builder's error, which breaks a MUST. *Rejected:* dropping the offending parameter, which loses a name the caller may be matching on | audit #67 / #76 | 2026-09-05 | `docs/product-spec/04-core-http-domain-model.md:42` carries `HTTP-31`'s wording (shared with `HTTP-30`). `packages/core/src/http/rfc3986.ts:17-18` are the two patterns, `:31` `hasLoneSurrogate` (strict) and `:44` `toWellFormed` (lenient) — one rule, two entry points, so no caller can pick the wrong one; `packages/core/src/http/query-params.ts:144-150` is `parse`'s repair with the `HTTP-18`/`HTTP-19` comparison stated inline, against `:44-50` and `:240-241` for the strict `add` path; `packages/core/src/seams/operation.ts:139-144` is the path-param half. `/\p{Surrogate}/u` rather than `String.prototype.isWellFormed()` because the latter is ES2024 and `tsconfig.base.json:5-11` pins `lib: ES2023`, though the `engines.node >= 20.3` runtime has it. Pinned by the `lone surrogates are rejected where they are supplied (HTTP-29, HTTP-31)` block in `packages/core/src/http/query-params.test.ts:170` — "parse() stays lenient and substitutes U+FFFD, because HTTP-31 forbids throwing" (`:197`) and the `no anything escapes parse()` property (`:233`) | not yet in §10 |
| **`OBS-35`'s "MUST NOT bake in a default config key name" is satisfied by making the key configurable, not by removing the default.** `OBS-35` (SHOULD) asks for a tolerant, layered log-level resolution and adds one MUST: no baked-in default key name. The port ships `CFG_KEY_LOG_LEVEL` (`DEXPACE_LOG_LEVEL`) as `CFG-14`'s well-known key and, until 2026-09-05, read it unconditionally. It is now `LoggingStepSettings.configKey`'s default: a caller names their own key and the resolution is otherwise identical. **Why the default stays.** A required key would mean no caller gets ambient granularity without naming one first, which trades a MUST about *naming* for a worse default experience, and `CFG-14` — which this port also implements — exists precisely to standardise the name. The layered resolution itself is `CFG-1`'s (override → environment → normalised property → default) and is tolerant as the requirement asks. **A second, quieter half:** the process-wide configuration slot starts empty (`CFG-13`), so no key of any name resolves until a host calls `setGlobalConfiguration(defaultConfiguration())`. That is deliberate — defaulting the slot to a configuration that reads `process.env` would make an import-time environment read the SDK's default behaviour — and it is now documented as the required wiring rather than left to be discovered | audit #67 / #80 | 2026-09-05 | `packages/core/src/observability/logging-step.ts:64-79,94-104` (the setting and the resolution); `packages/core/src/config/configuration.ts:311,354-358,368` (`CFG_KEY_LOG_LEVEL`, `defaultConfiguration`, the empty default slot); `docs/sdk-documentation/pipelines.md` "Turning logging on from the environment"; `docs/product-spec/15-instrumentation-and-observability.md:66` (the requirement) | not yet in §10 |
| **`CFG-22`'s SOCKS proxy types are resolved by the configuration layer and supported by neither shipped transport; the refusal is at the transport factory, and `ProxyType` keeps them.** `CFG-22` (MUST) requires the proxy model to carry "the proxy protocol type (HTTP, SOCKS4, SOCKS5)", and the port implements it in full: `ProxyType` is `'http' \| 'socks4' \| 'socks5'`, and `resolveProxyOptions` maps `ALL_PROXY`/`HTTPS_PROXY`'s `socks:`, `socks4:`, `socks4a:`, `socks5:` and `socks5h:` schemes onto it. Nothing can then send over one. `@dexpace/transport-undici` builds undici's `ProxyAgent`, which is an HTTP `CONNECT` tunnel reading its `uri` as a URL, and `@dexpace/transport-fetch` ships no `proxy` option at all because Node's bare global `fetch` exposes no proxy hook outside undici internals. So a configuration that resolves cleanly has no transport that can honour it. **What changed on 2026-09-05 (audit #67 / #81).** Until then the discovery was `new ProxyAgent({uri: 'socks5://…'})` throwing undici's `InvalidArgumentError('Invalid URL protocol: socks5:')` out of a public factory — untyped, undocumented, and outside the SDK's error vocabulary. `undiciTransport()` now refuses `proxy.type !== 'http'` at construction with a `TypeError` naming the type, before any dispatcher is allocated, deliberately outside the `IoError` tree so `retry/classify.ts`'s allow-list makes it non-retryable (RETRY-2). That is `TRANSPORT-30`'s "make the limitation discoverable rather than silently misbehaving" applied at the earliest point that can. **Why `ProxyType` still admits `socks4`/`socks5`.** Narrowing a `@public` union is a breaking change and therefore a release-pass decision, which this run is not taking (the run's release machinery is suspended); and `CFG-22`'s MUST is about the *model*, which would then no longer satisfy it. The honest state is a configuration layer that is complete and a transport layer that is not, which is what this row records. A future transport — a `node:net` SOCKS dialer, or a `ProxyAgent` replacement — closes the gap without a model change. | audit #67 / #81 | 2026-09-05 | `packages/core/src/config/proxy.ts:34` (`ProxyType`), `:372-380` (the scheme map); `packages/transport-undici/src/undici-transport.ts:138,151-158,192` (the supported type, the refusal, and where it runs); `packages/transport-fetch/src/fetch-transport.ts:76-79` (no `proxy` option, and why); `docs/product-spec/16-configuration.md:42` (`CFG-22`); `docs/product-spec/17-transport-adapter-conformance-contract.md:48` (`TRANSPORT-30`) | not yet in §10 |

### Proposed erratum for `PIPE-40` (drafted 2026-09-04, not applied)

Expand Down
62 changes: 47 additions & 15 deletions docs/sdk-documentation/write-a-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function echoTransport(): Transport {
Note `setInbound`, not `set`: values a server sent are accepted leniently. Using the strict setter on
a real server's headers means a response with an obs-text byte in it becomes unreadable.

## Nine rules a real transport must follow
## Eleven rules a real transport must follow

The full contract is `docs/product-spec/17-transport-adapter-conformance-contract.md`, thirty
`TRANSPORT-N` clauses. These are the ones that are easy to get wrong.
Expand All @@ -61,36 +61,65 @@ client, so forwarding a caller's copy corrupts framing. `Connection` is in the d
`fetch`-class transport and not for an undici-class one — §17 says so explicitly. Log the **name**,
never the value, and dedupe per name by default.

**3. Map aborts to exactly two errors** (`TRANSPORT-3`/`TRANSPORT-4`/`TRANSPORT-8`). A timeout is the
**3. Whatever your native client refuses, drop that header — never the request** (`TRANSPORT-12`).
This is the half that is easy to miss, because the refusal happens somewhere you are not looking.
`@dexpace/core` admits every printable ASCII byte in a header name (`http/ascii-validation.ts`), so
`X Custom` is a model-valid name no HTTP client on this platform will carry. Both shipped clients
also refuse `Expect`, `Keep-Alive` and `Upgrade` outright, and undici refuses `Connection` with any
value but `close`/`keep-alive`. Find out *where* your client decides: WHATWG `Headers.append` throws
at construction, which a `try`/`catch` degrades for free; undici validates inside `dispatch`, so
that transport has to ask the question itself before handing the array over. Getting this wrong
does not look like a bug in your transport — it looks like a retryable network failure that burns
the caller's whole retry budget re-proving a permanent misconfiguration. The shared suite has a row
per name.

**4. Map aborts to exactly two errors** (`TRANSPORT-3`/`TRANSPORT-4`/`TRANSPORT-8`). A timeout is the
retryable `TransportFailureError`; a caller abort is the terminal `CancellationError`. A raw
`DOMException` must never surface. `isTimeoutSignal(signal)` is how you tell them apart.

**4. An abort after delivery must not close the delivered body** (`SEAM-16`). Both native clients tie
**5. An abort after delivery must not close the delivered body** (`SEAM-16`). Both native clients tie
a response body's lifetime to the signal they were given, so dispatch over a **fork** of the signal
and detach it at delivery. Get this wrong and a caller who aborts a moment after `send()` resolves
finds the body they already own torn out from under them.

**5. The caller owns the response body** (`BODY-15`). Return it live and unread. Do not buffer it, do
**6. The caller owns the response body** (`BODY-15`). Return it live and unread. Do not buffer it, do
not close it.

**6. Ownership decides who closes what** (`SEAM-14`). A dispatcher or client the caller supplied is
**7. Ownership decides who closes what** (`SEAM-14`). A dispatcher or client the caller supplied is
never touched by your `close()`. One you constructed is yours to close. Make that decision once, at
construction, and make supplying both a caller-owned client *and* an option that would build one a
construction-time `TypeError` rather than a silent win for one of them.

**7. `close()` must be idempotent, concurrent-safe, and non-blocking** (`TRANSPORT-15`/`TRANSPORT-16`).
**8. `close()` must be idempotent, concurrent-safe, and non-blocking** (`TRANSPORT-15`/`TRANSPORT-16`).
No unbounded await — a graceful drain would stall teardown for as long as one in-flight send against
a slow peer takes. Destroying is the sanctioned choice; in-flight sends then reject with
`CancellationError`, and so does a `send()` issued after `close()`, because it cannot succeed over a
dispatcher that no longer exists and so is not a retryable failure. Declare your post-close mode
(`SEAM-15`) either way: `@dexpace/transport-fetch`'s `close()` is a documented no-op over a runtime
global it does not own, and `send()` keeps working after it.

**8. Recognize a file body structurally** (`TRANSPORT-28`). `body.kind === 'file'` widens the body to
`FileBodyDescriptor` — `path`, `start`, `count` — and lets you dispatch straight off the file. Never
`instanceof` against `@dexpace/body-file`: a transport must not depend on it.

**9. Send a real `User-Agent`** (`NFR-15`), never a placeholder. `getBuildInfo()` supplies the tokens.
**9. Recognize a file body structurally, and still write it through `writeTo`**
(`TRANSPORT-28`, `BODY-13`). `body.kind === 'file'` widens the body to `FileBodyDescriptor` —
`path`, `start`, `count`. Never `instanceof` against `@dexpace/body-file`: a transport must not
depend on it.

Reading `path` yourself is the trap. It is a shorter path to the wire, and it skips the
descriptor's own `writeTo`, which is where `BODY-13`'s `transferred === count` check lives — the
only thing that can notice a file truncated between `stat` and `send`, because `Content-Length` is
dropped outbound (rule 2) so the framing cannot. `@dexpace/transport-undici` did exactly this until
2026-09-05 and uploaded short files with a 200. Unless your client has a genuine kernel `sendfile`
path, treat a file body as an ordinary `Body` and let `writeTo` produce the bytes; `TRANSPORT-28`'s
zero-copy clause is a SHOULD, and its MUSTs — replayable, and exactly the declared range on the
wire — are the descriptor's to keep, not yours.

**10. Refuse a proxy you cannot honour, at construction** (`TRANSPORT-30`). `ProxyType` admits
`socks4` and `socks5`, and core resolves both from `ALL_PROXY`, so a configuration can hand you a
proxy your client cannot build. Reject it in the factory with a typed error that names the type,
before you allocate anything — not on the first send, where it arrives as whatever the native
client raises. Keep it outside the `IoError` tree: `retry/classify.ts` is an allow-list, so a
misconfiguration no retry can fix is then non-retryable for free. Declare it in `@throws`.

**11. Send a real `User-Agent`** (`NFR-15`), never a placeholder. `getBuildInfo()` supplies the tokens.

## Prove it

Expand All @@ -105,10 +134,13 @@ runTransportConformanceSuite('my-transport', () => myTransport(), {
supportsInternalCancel: false, // TRANSPORT-8: a cancel path distinct from a caller abort
supportsProxy: false, // TRANSPORT-30
dropsConnectionHeader: true, // TRANSPORT-11: is `Connection` in your drop set?
// TRANSPORT-30, optional: a proxy type your configuration can express and your client cannot
// honour. Omit it and the row asserts `supportsProxy` is false, rather than skipping.
// unsupportedProxy: {type: 'socks5', build: () => myTransport({proxy: socks5Proxy})},
});
```

The three capability flags are the only clauses §17 scopes to a subset of transports; everything else
Those capability entries are the only clauses §17 scopes to a subset of transports; everything else
runs unconditionally. The suite starts its own fixture server, and a second one on a separate origin
for the rows that deliberately leave a connection unusable — a client that reuses a poisoned
connection otherwise fails thirty rows downstream, which is a debugging problem of a different order.
Expand All @@ -120,15 +152,15 @@ The package is `private` and its `exports` name `./src/index.ts`, so it resolves

`@dexpace/transport-shared` exists so the algorithm both adapters need exists once. Its exports are
`@internal` and it is not a package to install directly, but reading it is the fastest way to see
what a correct implementation of rules 2, 3, 4 and 6 looks like:
what a correct implementation of rules 2, 3, 4, 5 and 7 looks like:

| Module | Concern |
|---|---|
| `header-mapping.ts` | The outbound drop-and-degrade pass and the lenient inbound copy |
| `header-mapping.ts` | Rules 2 and 3: the outbound drop-and-degrade pass, and the lenient inbound copy |
| `drop-log.ts` | Bounded, case-insensitive, drain-to-cap dedup of already-logged drop names |
| `abort-mapping.ts` | The single mapping from an aborted signal to `TransportFailureError` or `CancellationError` |
| `body-pump.ts` | Turning a `Body` into a request stream the transport owns, plus idempotent teardown for an abandoned producer |
| `signal-fork.ts` | Rule 4's fork-and-detach |
| `signal-fork.ts` | Rule 5's fork-and-detach |

## Package it

Expand Down
Loading
Loading