From dfb02aac0a083346404e06c2cfee4ec086ea5843 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 23:23:29 +0300 Subject: [PATCH 1/2] docs: add language-agnostic product and porting specification Add docs/product-spec.md, a normative, implementation-neutral specification of the SDK's behavior and contracts. It states the product thesis (an HTTP-client toolkit rather than a client), the pluggable seams and their discovery rules, the immutable HTTP model, both execution-pipeline layers, and the retry, redirect, authentication, pagination, streaming, serialization, observability, configuration, and transport/async-adapter contracts as RFC 2119 requirements. Each requirement carries a stable identifier, a rationale, and a conformance-test note, and the document closes with a glossary, a conformance checklist, and a consolidated requirement index, so the SDK can be reimplemented faithfully in another language or runtime. --- docs/product-spec.md | 1808 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1808 insertions(+) create mode 100644 docs/product-spec.md diff --git a/docs/product-spec.md b/docs/product-spec.md new file mode 100644 index 0000000..1efe320 --- /dev/null +++ b/docs/product-spec.md @@ -0,0 +1,1808 @@ +# dexpace SDK — Language-Agnostic Product and Porting Specification + +**Status:** Draft for implementation. Normative. This document specifies observable behavior and contracts, not any single language's API. It is versioned alongside the reference (Kotlin/JVM) implementation and supersedes prose scattered across per-subsystem design notes. + +**Audience:** Engineers porting the dexpace SDK to another language or runtime; maintainers of the reference implementation who need a single normative reference; and reviewers auditing a port for behavioral parity. Readers are assumed fluent in HTTP semantics (RFC 9110/9111), RFC 3986 URIs, and their target language's concurrency and resource-management model. + +**How to read this document.** Requirements use the RFC 2119 keywords **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** with their standard meanings. A **MUST** is a correctness or safety invariant; a port that violates it is non-conforming. A **SHOULD** is a strong recommendation whose deviation must be deliberate and documented, typically because the behavior is a reference-implementation choice rather than an interop invariant. A **MAY** marks an optional capability or an explicitly permitted latitude. + +Every normative statement carries a stable requirement identifier of the form `PREFIX-N` (for example `HTTP-3`, `RETRY-7`, `SEAM-30`). The prefix names the subsystem the requirement originates in: `SEAM` (extension model), `HTTP` (wire model), `IO` (streaming), `BODY` (payload lifecycle), `CTX` (execution context), `PIPE` (stage pipeline), `RECOV` (recovery chain), `RETRY` (resilience), `REDIR` (redirects), `AUTH` (authentication), and `NFR` (quality bar). These identifiers are load-bearing: cross-references between subsystems use them, and a conformance suite should tag each test with the identifier it exercises. Each requirement is presented as its identifier and level, a normative statement, a one-line rationale, and a one-line note on how to conformance-test it. Where the Kotlin/JVM SDK realizes a requirement in a way worth knowing, a short *Reference implementation:* note is added in italics; that note is informative only, and the requirement above it is the portable contract. + +**Scope.** This document covers the SDK's product thesis, its durable architectural principles, its pluggable extension seams, the immutable HTTP domain model, the byte-streaming and body-lifecycle contracts, the execution-context model, both execution-pipeline layers, and the resilience concerns layered on top of them: retry, redirect following, and authentication. A companion document (produced separately) covers pagination, server-sent events, serialization, instrumentation, configuration and utilities, the transport and async-runtime adapter contracts, cross-cutting invariants, and the non-functional quality bar; requirements from those subsystems are cited here where they cross a boundary. + +**Non-goals of this specification.** This document does not prescribe an implementation language, a concurrency framework, a build system, or a wire library. It does not define a code-generation layer or DTO model; it specifies the runtime primitives generated code would rely on. It does not restate HTTP, TLS, or URI semantics that the referenced RFCs already define, except where the SDK deliberately narrows or tolerates them. It does not mandate the reference implementation's exact class names, package layout, or numeric tuning constants except where a constant is itself part of the contract (those are called out explicitly). Finally, it contains no "Consolidated Normative Requirement Index"; that index is generated mechanically and appended after this text. + +## 1. Product Overview + +The dexpace SDK is an **HTTP-client toolkit, not an HTTP client**. Its core supplies immutable wire models, a staged request/response pipeline, recovery-aware resilience primitives, and a small set of narrow abstractions; it does not itself open sockets, encode JSON, or read bytes off a stream. Every capability that touches the outside world — the network transport, the byte-stream implementation, the wire codec — plugs in behind a single-purpose interface, and the asynchronous-runtime concern is handled through a canonical async pivot plus optional out-of-core adapter modules. The product's own framing is that it is "the machinery an HTTP client is made of," not the client itself. + +The primary consumers are two audiences that share one codebase. The first is authors of generated or hand-written service clients who need correct, secure, observable HTTP plumbing — retries that never double-send a one-shot body, redirects that never leak a bearer token cross-origin, logging that never prints a secret — without re-solving those problems per service. The second is application teams who want to bring their own transport, JSON library, and async runtime and pay only for what they put on the classpath. Both are served by the same seam design. + +The value proposition follows directly from the toolkit thesis. Because the core carries no concrete transport, codec, or I/O dependency (**SEAM-1**), a consumer can adopt it without inheriting a transitive-dependency conflict, and can swap any one concern — change transports, change JSON libraries, change async runtimes — without touching the others. The correctness-sensitive decisions (idempotency classification, status ranges, body replayability, header-injection defenses, credential hygiene, cancellation semantics) are made once, in the core, so every transport behaves identically. This is why a faithful port must preserve the seams and their invariants rather than reimplement each concern per adapter. + +The seam philosophy, at a glance: the core depends on a small enumerated set of interfaces it never implements — a byte-stream provider, a synchronous transport, an asynchronous transport, a wire codec, and an operation-input projection (**SEAM-2**) — and each concrete implementation lives outside the core in its own module that depends on the core plus at most one third-party library (**NFR-2**). A single implementation on the classpath is auto-discovered with no bootstrap call; zero or multiple candidates fail loudly (**SEAM-5**). The async-runtime concern is deliberately *not* a core interface: it is decoupled through a canonical, dependency-free async future type that every ecosystem adapter bridges to and from (**SEAM-17**). + +## 2. Architectural Principles + +These are the durable principles a port must preserve even as individual subsystems evolve. They are stated here once and referenced throughout. + +**Pluggable seams over embedded implementations.** Each external concern is exposed as exactly one narrow interface the core depends on but never implements, and the core never references a concrete implementation by name. + +- **SEAM-1** (**MUST**): The core library MUST NOT embed a concrete HTTP transport, byte-stream I/O implementation, or wire codec, and MUST depend at runtime on nothing beyond its language's standard library plus a compile-time-only logging facade. *Rationale:* a dependency-free core lets consumers pay only for the runtimes they choose and swap any concern independently. *Conformance:* a dependency audit of the core module finds only the standard library plus the compile-scope logging facade; no transport/codec/stream symbol is referenced from core. +- **SEAM-2** (**MUST**): Each external concern that has a core-owned contract MUST be exposed as exactly one narrow interface — the enumerated seams are byte-stream provider, synchronous transport, asynchronous transport, wire codec, and operation-input→request projection — and the core MUST NOT reference any concrete implementation of a seam by name. *Rationale:* one seam per concern keeps the dependency surface flat and each capability independently replaceable. *Conformance:* substitute a fake implementation of each seam and confirm the core operates unchanged. + +**Immutability of shared state.** Every value that crosses a concurrency or API boundary is immutable after construction; change is expressed by producing a new instance. + +- **HTTP-1** (**MUST**): All core domain-model types MUST present an immutable value/metadata surface after construction, safe to share across threads without external synchronization; any change MUST produce a new instance. The single carve-out is a body that wraps live single-use stream state (§6). *Rationale:* the model is the shared boundary handed to concurrent transports and pipelines; a mutable metadata surface would race. *Conformance:* mutate the originating builder after construction and assert the built instance is unchanged, from multiple threads. +- **SEAM-29** / **HTTP-2** (**MUST**): Model construction MUST go through an immutable-value + Builder (or dedicated factory) pattern; there MUST be no public field-wise constructor or unchecked copy that bypasses validation. *Rationale:* validation and normalization live in the builder/factory; a bypass would let invalid instances exist. *Conformance:* verify no construction path skips builder validation (e.g. deriving a request cannot install a body-carrying GET). + +**Single-sourced correctness.** A decision that must stay consistent across the SDK is computed from one definition, never duplicated. + +- **HTTP-9 / RETRY-1 / RETRY-6** (**MUST**): The idempotent-method set `{GET, HEAD, OPTIONS, PUT, DELETE}` and the retryable-status classifier (408, 429, and all 5xx except 501 and 505) MUST each be single-sourced, so the retry allow-list, the inherent replay-safety gate, and the exception's baked retryable flag all derive from one place. *Rationale:* divergent copies of these classifications produce inconsistent retry behavior. *Conformance:* table-drive every method/status against the single definition and against each consumer of it. +- **RETRY-13** (**MUST**): Both retry stacks MUST compute backoff via one shared calculator using one shared set of constants; neither may carry an independent formula. *Rationale:* the explicit anti-drift guarantee. *Conformance:* feed identical settings to both stacks with jitter neutralized and assert identical delay sequences. + +**Security by default.** Safe behavior is the default; exposing a secret or lowering a guarantee must be an explicit opt-in. + +- Credentials are transport-scoped and never stamped over plaintext (**AUTH-28**); redirects strip credentials and never launder them cross-origin (**REDIR-7**, **REDIR-9**, **REDIR-8**); header names and values are validated against request-splitting before any transport sees them (**HTTP-17**–**HTTP-19**); Digest client nonces come from a cryptographically strong source (**AUTH-20**); and log-preview and error-body buffers are bounded (**HTTP-52**, **BODY-30**). + +**Cancellation correctness.** Cancellation is distinct from timeout, propagates into the network layer, and never silently disappears. + +- **SEAM-13** (**SHOULD**): Blocking transports SHOULD honor cooperative cancellation during blocking I/O; async transports SHOULD treat cancelling the returned future as a best-effort abort of the in-flight exchange. *Rationale:* cancellation must reach the network layer to free resources promptly. *Conformance:* interrupt a parked blocking send and assert it unwinds; cancel an async future and assert the transport's cancel hook fires. +- Cancellation is terminal and non-retryable, and MUST be told apart from a retryable timeout out-of-band, never by matching an error message (see §9, **RETRY-23**, **RETRY-24**). + +**Minimal-footprint core.** The core stays small and dependency-free so a consumer's footprint is proportional to the features actually used (**SEAM-1**, **NFR-1**, **NFR-2**). Optional capabilities — each transport, codec, I/O backend, and async bridge — are separately installable units depending on the core plus at most one third-party library. + +## 3. Pluggable Seams and Extension Model + +The SDK's extensibility rests on a fixed, small set of interface seams plus the async pivot. This section specifies each seam's contract, how a plug-in is discovered and resolved, and who owns the lifecycle of what. + +### 3.1 The byte-stream provider seam + +- **SEAM-3** (**MUST**): The byte-stream provider MUST expose factory operations to create a new empty in-memory buffer, a buffered reader over a raw input stream, a buffered reader over a byte array, a buffered writer over a raw output stream, and wrappers that add the buffered surface to a primitive source/sink. A reader/writer created over a caller's raw stream TAKES OWNERSHIP of that stream — closing the reader/writer closes it. *Rationale:* this is the single seam between core and a concrete streams library; ownership-on-wrap lets pipeline code close one object and know the descriptor is released. *Conformance:* wrap a spy stream, close the returned reader/writer, assert the spy closed exactly once. +- **SEAM-4** (**MUST**): Provider factory operations MUST be safe to invoke concurrently from many threads; the buffer/reader/writer instances they return are NOT required to be thread-safe and are confined to a single logical operation. *Rationale:* factories are called under concurrency, but per-stream objects are cheap and confined. *Conformance:* hammer the factories from N threads and assert no shared-state corruption. + +### 3.2 The synchronous transport seam + +- **SEAM-11** (**MUST**): The sync transport MUST be a single-operation contract: given one request, produce one response, and MUST NOT pre-buffer the response body — the caller owns reading and closing it. It MAY accept per-call options; a transport that ignores options MUST behave identically to the no-options call. *Rationale:* keeping the seam to "send one, get one" is what lets pipelines, retry, auth, and logging be built above it. *Conformance:* a bare send lambda works as a transport; passing options to an options-ignoring transport matches omitting them. +- **SEAM-12** (**MUST**): Transports (sync and async) MUST be safe for concurrent calls, with all per-request state confined to locals or the returned response/future graph. *Conformance:* fire many concurrent requests through one transport and assert no cross-talk. + +### 3.3 The asynchronous transport seam and the canonical pivot + +- **SEAM-16** (**MUST**): The async transport's returned future MUST complete either with a non-null response (caller owns closing it) or exceptionally; it MUST NOT complete successfully with a null/absent value. Cancelling an already-completed success future does NOT close the delivered response body — the caller MUST still close it. *Rationale:* a null success is a latent dereference at every call site. *Conformance:* an implementation returning a null-completed future is non-conformant; a delivered response still requires explicit close. +- **SEAM-17** (**SHOULD**): The async transport contract SHOULD be expressed in terms of one canonical, dependency-free async primitive (a future completing with a value or exceptionally) that serves as the interop pivot; ecosystem facades (coroutines, reactive streams, event-loop futures, virtual threads) SHOULD be separate adapter modules bridging to and from that pivot, preserving cancellation and error semantics with per-adapter caveats documented. *Rationale:* a lowest-common-denominator pivot lets every async ecosystem interoperate through one point instead of the core depending on any one runtime. *Conformance:* each adapter translates success, exceptional completion, and cancellation of the pivot into its ecosystem's equivalents and back. *Reference implementation: the pivot is `java.util.concurrent.CompletableFuture`.* +- **SEAM-18** (**MUST**): The provided sync↔async bridges MUST preserve semantics: wrapping a blocking transport as async REQUIRES a caller-supplied executor (there is intentionally no default, and a shared global fork/join-style pool is explicitly unacceptable because a blocking call would starve it); wrapping an async transport as blocking MUST unwrap the async-wrapper exception so callers see the original failure, and the blocking wait MUST honor interruption (restore the interrupt flag, cancel the in-flight future, surface an interrupted-I/O error). Per-call options MUST be threaded through, not dropped. *Conformance:* the async-wrapping bridge has no zero-arg overload; the blocking bridge surfaces the original exception; interrupting it cancels the future. +- **SEAM-30** (**MUST**): On any path where a send produces a response the returned future will NOT hand to a caller — because the future was already cancelled or completed exceptionally (the completion race) — the producer MUST close that orphaned response so its connection/descriptor is not leaked. *Rationale:* the caller-closes rule (SEAM-16) cannot apply to a value no caller receives. *Conformance:* cancel the future before an in-flight send completes, then let it produce a response; assert the response is closed exactly once and never surfaces. + +### 3.4 The wire-codec (serde) seam + +- **SEAM-19** (**MUST**): The wire-codec seam MUST bundle a serializer, a deserializer, and the media type its serializer produces; the media type MUST NOT be defaulted at the seam level — each codec declares its own. *Rationale:* an undefaulted content type prevents a whole class of silent mislabeling bugs at the request-body edge. *Conformance:* a JSON codec reports the JSON media type and the core uses it as the default request-body content type. +- **SEAM-21** (**MUST**): Deserialization MUST require an explicit runtime type token rather than an erased/inferred generic, so a language with type erasure recovers the intended type instead of silently producing a generic map/list. Parametric targets MUST be expressible through a full generic type capture. *Rationale:* the defense against erasure-driven heap pollution. *Conformance:* decoding into a concrete type via the token yields a typed value; a parametric type on a no-codec deserializer fails loudly. + +### 3.5 The operation-input projection seam + +- **SEAM-26** (**MUST**): The operation-input projection MUST let generated code declare, per operation, an HTTP method, a path template with named placeholders, and typed projections of inputs onto path / query / header / body, so operation arguments flow through typed projections rather than URL string surgery. Only method and path template are required; the four projections default to empty. The body is carried, not encoded, by this seam. *Conformance:* a parameterless GET overriding only method+path assembles the right request; inputs placed via projections appear in the correct request part. +- **SEAM-27** (**MUST**): When assembled against a base URL, path-parameter values MUST be percent-encoded as single path segments (a value cannot inject extra `/`), every placeholder MUST have a supplied value, the query MUST be RFC-3986 rendered, and base-URL composition MUST follow fixed rules: a trailing slash normalizes to one separator, an empty path leaves the base untouched, an existing base query is preserved with the operation query appended after it, and a base carrying a fragment or resolving to a malformed URL is rejected with a context-bearing error. *Rationale:* these invariants make typed projection safe against path injection and support signed/SAS bases. *Conformance:* a path value containing `/` is encoded not split; a missing placeholder throws; `host/c?sig=..` + `/pets` → `host/c/pets?sig=..&`. + +### 3.6 Discovery, registration, and conflict resolution + +- **SEAM-5** (**MUST**): Provider resolution MUST follow a fixed precedence: an explicitly installed provider always wins; otherwise the runtime auto-discovers providers registered on the classpath/plugin registry. Resolution MUST throw a descriptive error when ZERO providers are discoverable (telling the caller to install one) and when MORE THAN ONE distinct provider is discoverable (listing all candidates). Exactly one discoverable provider is selected silently. *Conformance:* no provider → install-hint error; two distinct → error listing both; one → returns it. +- **SEAM-6** (**MUST**): Explicit installation MUST be idempotent for the same instance and MUST reject installing a DIFFERENT provider when one is already installed, naming both. *Conformance:* install(A) then install(A) is a no-op; install(A) then install(B≠A) throws naming both. +- **SEAM-7** (**MUST**): A successful auto-resolution MUST be cached process-wide; an UNRESOLVED state (zero or multiple, which throws) MUST remain re-evaluable so a later-registered provider (or explicit install) can still take effect. *Rationale:* caching a failure would permanently wedge a process that later gains a provider. *Conformance:* first success scans, second does not; a failing access re-scans next time. +- **SEAM-9** (**MUST**): Resolution/install/swap state MUST be concurrency-safe: reads observe the latest install without blocking, writes are serialized so two concurrent installs cannot both pass the conflict check, and a concurrent first-access cannot run the discovery scan twice. *Conformance:* race N threads installing the same instance and reading; assert exactly-once resolution and no torn state. +- **SEAM-8** (**SHOULD**): When an explicit install replaces a DIFFERENT provider that had already been auto-resolved AND handed out, the runtime SHOULD emit a warning (not fail), because objects may already exist against the previous provider. *Conformance:* auto-resolve A, hand it out, install B → warning; install before any access → no warning. +- **SEAM-10** (**SHOULD**): The registry SHOULD tolerate one logical provider seen through more than one loader without misreporting it as multiple, de-duplicating by concrete implementation identity, and SHOULD recognize a thin delegating shim as its canonical target. *Reference implementation: resolved via a `ServiceLoader` over classpath service entries, with a registration shim because a JVM singleton object cannot be reflectively instantiated.* + +### 3.7 Resource ownership: bring-your-own vs SDK-managed + +- **SEAM-14** (**MUST**): Both transport seams MUST be closeable, and close MUST be (1) idempotent, (2) ownership-aware — only resources the transport itself created are released, and a caller-supplied client/executor is NEVER touched, and (3) interrupt-safe. A lightweight transport MAY have a no-op close. *Rationale:* closing a caller's shared client would break the rest of their application. *Conformance:* build a transport over a BYO client, close the transport, assert the BYO object is still usable; build an SDK-managed transport, close it, assert the owned pool/executor is released. +- **SEAM-25** (**MUST**): An async-runtime adapter that OWNS an executor MUST implement close as an idempotent, ownership-aware release: only the first close shuts the owned executor and emits the lifecycle event. Closing MUST NOT be required to cancel in-flight requests (a graceful drain is acceptable), and an adapter built over a caller-supplied executor MUST NOT shut it down. *Conformance:* close twice → executor shut once, one event; in-flight tasks complete rather than force-interrupted; a BYO executor is untouched. +- **SEAM-24** (**SHOULD**): Async-runtime adapters that hand work to another thread SHOULD propagate the ambient logging/diagnostic context across the thread handoff, and SHOULD map cancellation bidirectionally per that ecosystem's idiom. *Conformance:* set a diagnostic value, run an async request through the adapter, assert a worker log carries it; cancel from each side and assert the other observes it. + +### 3.8 Shared construction contract + +- **SEAM-29** (restated for the seam layer) (**MUST**): A shared generic Builder contract (`build()` producing the target type) MUST exist so generic composition helpers can accept any builder, and required-field validation MUST be uniform: a missing required field fails at `build()` with a consistent message of the form ` is required`. *Conformance:* omitting a required field yields exactly ` is required`; a builder is assignable where the generic Builder contract is expected. + +## 4. Core HTTP Domain Model + +The domain model is the immutable, transport-agnostic, security-critical boundary between application code and the wire. It fixes case-insensitivity, multi-value semantics, ordering, header-injection defenses, method/body legality, and total status handling once, so every transport behaves identically. + +### 4.1 Construction, immutability, and derivation + +- **HTTP-3** (**MUST**): Each builder-based model (request, response, headers, query params, request options, request conditions, multipart body) MUST expose a `newBuilder()`-style derivation returning a builder pre-populated from the instance, and that pre-filled builder MUST NOT alias the original's internal collections (each value list is copied). Value-based types with no builder (media type, status, the typed header name, ETag, HTTP range, method, protocol) are derived by re-constructing through their factories. *Conformance:* derive, mutate the builder, build a second instance, assert the first is unchanged. +- **HTTP-4** (**MUST**): `build()` MUST validate required fields and fail with a field-named error when one is missing (a request requires its URL; a response requires request, protocol, status), never silently substituting defaults except where explicitly specified. *Conformance:* a request with no URL and a response with no status each throw naming the missing field. +- **HTTP-5** (**MUST**): Accessors returning collections of header/query names, values, or entries MUST NOT let a caller mutate the model through the returned value, and MUST NOT surface later mutations of a live builder. Isolation from a builder is guaranteed by a build-time deep copy of every value list; isolation from mutation-through-the-collection is guaranteed by returning read-only-typed collections. A port in a language without read-only views MUST reproduce this with unmodifiable wrappers or per-call defensive copies. *Conformance:* retrieve name/entry/value accessors, mutate the source builder, assert a previously-returned snapshot is unchanged; confirm a returned value list cannot be downcast-and-mutated to reach the shared model. + +### 4.2 Request and method legality + +- **HTTP-6** (**MUST**): A request MUST carry exactly method, target URL, headers (non-null, possibly empty), and an optional body; a response MUST carry the originating request, negotiated protocol, status, an optional reason phrase, headers (non-null, possibly empty), and an optional body. Operational knobs (timeout, retries) live outside the wire model (§4.5). +- **HTTP-7** (**MUST**): The builder MUST reject a non-null body on any method whose classification forbids one (GET, HEAD, TRACE, CONNECT), failing at construction rather than deferring to the transport. *Rationale:* reference transports diverge (one throws, one silently drops the body); rejecting once yields one portable behavior. *Conformance:* a GET/HEAD/TRACE/CONNECT with a non-null body throws; clearing the body succeeds. +- **HTTP-8** (**SHOULD**): With no method set, `build()` SHOULD default to GET only if no body is present; a body with no method SHOULD fail reporting a missing method rather than defaulting to GET and then tripping the no-body-on-GET rule. *Conformance:* neither method nor body → GET; body with no method → error mentioning the missing method. +- **HTTP-9** (**MUST**): The model MUST define an idempotency classification — the set `{GET, HEAD, OPTIONS, PUT, DELETE}` — as the single source both the configurable retry allow-list and the inherent replay-safety gate derive from, and each method's canonical wire token MUST equal its uppercase name. *Conformance:* assert idempotent-set membership per method and that each wire token equals the uppercase name. +- **HTTP-46** (**MUST**): Request URL equality/hashing MUST NOT perform blocking work or DNS resolution: URLs MUST be compared by textual external form, and request equality otherwise compares method, headers, and body by value. *Rationale:* some platforms' native URL equality resolves the host (blocking, and wrong for virtual hosts sharing an IP). *Conformance:* two requests to the same textual URL are equal with no network access; two textually different URLs resolving to the same host are not equal. +- **HTTP-47** (**SHOULD**): Building a request from a malformed URL string or non-absolute URI SHOULD fail with an argument error carrying the offending input. *Conformance:* `url("::bad")` and a relative URI throw an argument error naming the input. + +### 4.3 Status + +- **HTTP-10** (**MUST**): Status MUST be a total function of the integer code: mapping any code returns a Status (never throws), with a canonical named instance for recognized codes and a raw-code, null-named instance for unrecognized ones. A separate lookup MUST let callers distinguish recognized codes (absent for unknown). *Rationale:* transports must faithfully surface vendor codes (nginx 499, Cloudflare 520–526/530). *Conformance:* map 200 → named OK; map 599/799 → a Status with that code, no name, no exception. +- **HTTP-11** (**MUST**): Status MUST classify by range: informational 100–199, success 200–299, redirect 300–399, client-error 400–499, server-error 500–599, and error 400–599, and a response MUST expose these derived from its status. *Conformance:* representative codes classify as expected; 400–599 → isError. +- **HTTP-12** (**MUST**): Two Status values MUST be equal iff their numeric codes are equal (name does not participate). *Conformance:* a code-derived Status equals the corresponding canonical constant and hashes identically. + +### 4.4 Headers, media type, protocol, query + +- **HTTP-13** (**MUST**): Header names MUST be treated case-insensitively for storage, lookup, containment, mutation, removal, equality, and hashing, folding to lower case with an ASCII/invariant rule (never a locale-sensitive fold). *Conformance:* a name added under one casing resolves under any other; a name containing `I` folds to `i` regardless of platform locale. +- **HTTP-14** (**MUST**): The header model MUST support multiple values per name — add appends, set replaces the whole list — preserving per-name insertion order. *Conformance:* add(a) then add(b) → [a,b]; set(c) → [c]. +- **HTTP-15** (**MUST**): Setting a header value to null MUST remove the header entirely. *Conformance:* set then set-null → not contained. +- **HTTP-16** (**SHOULD**): The model SHOULD preserve insertion order of distinct names for deterministic serialization, caching, signing, and test stability. *Conformance:* names iterate in insertion order. +- **HTTP-17** (**MUST**): Outbound (caller-set) header names MUST be validated at construction: reject a blank name, any C0 control (0x00–0x1F, including CR/LF/NUL) or DEL (0x7F), and any non-ASCII byte (≥ 0x80); surrounding whitespace is trimmed before validation. *Rationale:* an embedded CR/LF is a header-splitting vector; non-ASCII names cannot be encoded by any reference transport. *Conformance:* reject `""`, `"a\r\nb"`, `"a\0"`, `"héader"`; accept `" X-Trace "` storing `X-Trace`. +- **HTTP-18** (**MUST**): Outbound header values MUST reject any control character (C0 and DEL) EXCEPT horizontal tab (0x09), and reject any non-ASCII byte. The accepted set is HTAB plus printable ASCII 0x20–0x7E. *Rationale:* blocks CR/LF splitting; takes the most restrictive shipped-transport stance. *Conformance:* reject `"a\r\nb"`, `"a\0"`, `"vålue"`; accept `"a\tb"`. +- **HTTP-19** (**MUST**): The model MUST provide a distinct lenient path for inbound (response) header values that RELAXES the non-ASCII rule (obs-text ≥ 0x80 permitted) while STILL rejecting control characters (C0 except HTAB, plus DEL); inbound names remain strictly validated. *Rationale:* RFC 7230 allows obs-text in field values (e.g. a Latin-1 Content-Disposition filename); applying the outbound grammar to responses would silently drop legitimate headers. *Conformance:* inbound value with a 0x80+ byte accepted; inbound value with CR/LF rejected; inbound name with a non-ASCII byte rejected. +- **HTTP-20** (**MUST**): Validation error messages MUST NOT echo the offending header value verbatim and MUST escape any control characters in an echoed header name. *Rationale:* prevents log injection and secret leakage through error messages. *Conformance:* a rejected value never appears in the message; a rejected name with an embedded CR appears escaped. +- **HTTP-21** (**MUST**): A typed header-name abstraction MUST compare/hash by its case-folded form while preserving original casing for wire emission, MUST interoperate with the string-keyed API, and MUST enforce the same name validation as HTTP-17. **HTTP-22** (**MAY**): it MAY intern instances process-wide (first casing wins); interning is an optimization, and the observable contract is value equality by case-folded name. +- **HTTP-23** (**MUST**): Media-type construction MUST lower-case the type, subtype, and every parameter KEY while preserving each parameter VALUE's case; equality is case-insensitive on type/subtype/keys and case-sensitive on values. *Conformance:* `Application/JSON;Charset=UTF-8` → `application/json`, key `charset`, value `UTF-8`. +- **HTTP-24** (**MUST**): Media type MUST resolve its charset parameter case-insensitively and return null (not throw) when absent or unknown, so callers fall back to a default. *Conformance:* `charset=utf-8` → UTF-8; `charset=bogus` → null; no charset → null. +- **HTTP-25** (**MUST**): Media-type parsing MUST split parameters respecting quoted-strings (a `;` or `=` inside quotes is not a separator), split each parameter on its FIRST `=` only, strip quotes, and unescape quoted-pairs; rendering MUST emit a value bare when it is a valid token and quoted-and-escaped otherwise, so `parse(render(x)) == x`. **HTTP-53** (**MUST**): parsing MUST reject blank input and require a non-empty type before and non-empty subtype after a single `/`, and each parameter must contain `=` with a non-empty key and value. **HTTP-26** (**MUST**): construction MUST reject a control (C0 except HTAB, plus DEL) or non-ASCII byte anywhere, using the same predicate as outbound header-value validation, so a media type is always header-safe. **HTTP-27** (**SHOULD**): wildcard matching SHOULD permit a wildcard type only with a wildcard subtype (bare `*/*`), with a wildcard in either position matching any value and parameters ignored. +- **HTTP-28** (**MUST**): Query-parameter names MUST be case-sensitive (no folding), preserve insertion order, support multiple values, and model a value-less parameter (`?flag`) as a single empty-string value distinct from an absent name. *Conformance:* `page` and `Page` are distinct; `add("flag", null)` → `get("flag") == ""` and contains true; absent name → get null. +- **HTTP-29** (**MUST**): Query encoding MUST render each name/value with RFC 3986 percent-encoding (space → `%20`, literal `+` → `%2B`, `/` → `%2F`, `*` → `%2A`), encoding everything except the unreserved set `A–Z a–z 0–9 - . _ ~`, preserving insertion order, emitting a repeated name once per value, omitting the leading `?`, and returning empty when empty. This is NOT `application/x-www-form-urlencoded`. **HTTP-32** (**SHOULD**): a single component's encoding SHOULD follow RFC 3986 independent of stdlib quirks — space is `%20` never `+`, a literal `+` is `%2B`, decoding leaves `+` as `+`, `~` stays, `*` is encoded. *Conformance:* `{q:["a b"], plus:["c+d"]}` → `q=a%20b&plus=c%2Bd`. +- **HTTP-30** (**MUST**): Query equality MUST be order-sensitive — two instances equal iff they encode identically — and a name whose value list is empty MUST be dropped at build time so it cannot leave a phantom contains-true entry invisible to encode. **HTTP-31** (**MUST**): parsing MUST invert encode and be lenient — a null/blank query → empty, a leading `?` tolerated, a segment with no `=` or a trailing `=` → empty-string value, stray `&` skipped, malformed percent-encoding falling back to raw text rather than throwing. +- **HTTP-33** (**MUST**): Protocol MUST expose a canonical lower-case wire form (`http/1.1`, `http/2`) and a locale-invariant, case-insensitive parse accepting the canonical forms plus the aliases `HTTP/2` and `HTTP/2.0`, throwing on an unrecognized identifier. + +### 4.5 Request options (operational overrides) + +- **HTTP-34** (**MUST**): Request options MUST model per-call operational overrides that are NOT part of the wire form — at minimum a per-call timeout, a per-call max-retries, and opaque string-keyed tags — with every field defaulting to a null/empty "use the default" sentinel, and MUST provide a canonical EMPTY "override nothing" instance. Tags MUST be defensively copied at build. *Conformance:* EMPTY has null timeout, null max-retries, empty tags; a built options is unaffected by later mutation of the source map. +- **HTTP-35** (**MUST**): The options builder MUST reject a non-null timeout that is zero or negative and MUST reject a negative max-retries; a max-retries of 0 MUST be accepted and mean "disable retries for this call". *Rationale:* zero-timeout means "no timeout" in one transport but is an error in another; a negative retry count would be silently reinterpreted. *Conformance:* zero/negative timeout throws; null timeout accepted; max-retries −1 throws; 0 accepted. + +### 4.6 Conditional-request helpers + +- **HTTP-48** (**SHOULD**): An ETag helper SHOULD model strong (`"opaque"`), weak (`W/"opaque"`), and the any singleton (`*`), validate permitted etagc characters (rejecting a literal quote, control chars, DEL; permitting obs-text), reject an empty strong opaque, permit an empty weak opaque, round-trip its raw form, reject unterminated forms, and return absent for blank input. +- **HTTP-49** (**SHOULD**): An HTTP-range helper SHOULD provide validated factories for a bounded range (rejecting negative offset / non-positive length, detecting overflow), a suffix range, and an open-ended range, supporting only the `bytes` unit and a single range (rejecting multi-range commas) and storing a parsed value verbatim. +- **HTTP-50** (**SHOULD**): A conditional-requests aggregator SHOULD emit `If-Match`/`If-None-Match` as one comma-separated header, emit `If-Modified-Since`/`If-Unmodified-Since` as RFC 1123 dates, be idempotent when applied (using set, not add), and enforce that the any-tag (`*`) is mutually exclusive with concrete entity-tags (collapsing repeated `*` to one). + +## 5. I/O Contracts + +The I/O layer is a minimal, pluggable byte-streaming abstraction on which bodies, body-logging snapshots, and serde are built. The core ships only interfaces; a concrete implementation is installed or auto-discovered per §3.6. A port reproduces these behavioral contracts on top of whatever byte-stream library its host language offers, so HTTP bodies stream with no mandatory full-buffering, logging captures bounded previews without corrupting the wire body, and retries re-read replayable bodies via non-consuming views. + +### 5.1 Primitive read/write protocol + +- **IO-1** (**MUST**): A source read MUST append bytes to the TAIL of a caller-provided destination buffer (never overwriting existing content) and return the number transferred: at least 1 when the requested count is positive and the source is not exhausted, exactly 0 when the requested count is 0, −1 at end-of-stream, and never more than requested. *Conformance:* read into a non-empty destination and assert new bytes are appended; a partial source returns >0 then eventually −1. +- **IO-2** (**MUST**): A read of count 0 MUST return 0 and MUST NOT report end-of-stream, even on an exhausted source. *Rationale:* underlying libraries often collapse a zero-byte read against an exhausted stream to −1, making callers falsely conclude EOF. *Conformance:* on both a non-empty and a fully-exhausted source, read(dest, 0) == 0. +- **IO-3** (**MUST**): A negative count passed to any size-taking read/write/copy MUST be rejected as an argument-validation error before any I/O; a port MAY use whichever argument-error type is idiomatic. *Conformance:* each size-taking method with −1 raises before any transfer. +- **IO-4** (**MUST**): A sink write MUST remove exactly the requested number of bytes from the HEAD of the source buffer and push them downstream; if the source holds fewer, it MUST fail with an I/O error rather than write a partial amount. *Conformance:* writing N from a buffer of exactly N empties it in order; requesting N+1 from an N-byte buffer throws. +- **IO-5** (**MUST**): A sink MUST expose flush (pushing buffered bytes toward the destination), and both source and sink MUST be closeable. **IO-18** (**SHOULD**): the sink surface SHOULD distinguish emit (a cheap one-level handoff) from flush (a full force-out); a pure in-memory buffer MAY make both no-ops returning self. + +### 5.2 Buffer semantics + +- **IO-7** (**MUST**): A buffer MUST behave as a FIFO byte queue that is simultaneously a source and a sink — bytes written through the sink surface read back through the source surface in exact order — with a size reflecting bytes currently held. +- **IO-8** (**MUST**): A buffer snapshot MUST return a fresh, independent byte-array copy of the current contents without consuming or mutating the buffer, so later mutations do not affect a returned snapshot and vice versa. *Conformance:* snapshot, write more, assert the first snapshot is unchanged. +- **IO-9** (**SHOULD**): Materializing an entire buffer (or a length-bounded slice read) as one contiguous byte array SHOULD refuse sizes exceeding the host's maximum single-array allocation, failing with an actionable message pointing at streaming alternatives rather than a low-level allocation crash. *Rationale:* multi-GB bodies must stream. +- **IO-10** (**MUST**): Clear MUST discard every byte, and copy-to MUST copy a specified window into another buffer WITHOUT consuming or mutating the source, defaulting to "from offset through end" and rejecting out-of-range windows. *Conformance:* copy a mid-buffer window and assert the source size is unchanged. +- **IO-41** (**MUST**): Closing a source, sink, or buffer MUST be idempotent: a second close MUST NOT throw, and the underlying resource is closed at most once. *Conformance:* close twice and assert no exception and at-most-one underlying close. +- **IO-42** (**MUST**): A buffered source/sink wrapping an external stream MUST reject read/write/flush/emit after close with an I/O error; a purely in-memory buffer is exempt on its own read/write surface (so snapshot-after-close logging still works) but its close MUST still invalidate every slice derived from it. *Rationale:* porters commonly get this inconsistency wrong in one of two directions — leaving a closed stream-backed sink writable (a resource-safety hole), or making an in-memory buffer throw after close (breaking snapshot-after-close body logging). + +### 5.3 Typed reads and lines + +- **IO-11** (**MUST**): `exhausted()` MUST return true exactly when no more bytes are available (and may block waiting on an upstream source), a single-byte read MUST return the next byte or fail with EOF, and a count-less byte-array read MUST return all remaining bytes (empty when already exhausted). +- **IO-12** (**MUST**): An exact-count read MUST return exactly the requested count or fail with EOF; it MUST NOT return a short result. *Rationale:* length-prefixed framing needs all-or-nothing exact reads. +- **IO-13** (**MUST**): UTF-8 and explicit-charset reads MUST decode with the specified encoding, with symmetric write-side encodings. *Conformance:* round-trip non-ASCII text through UTF-8 and through a non-UTF-8 charset (e.g. ISO-8859-1). +- **IO-14** (**MUST**): A UTF-8 line read MUST consume the next line terminator and return the preceding bytes as UTF-8, treating both `\n` and `\r\n` as terminators, returning null when exhausted before any byte, returning a final unterminated line as-is, and keeping a lone `\r` not followed by `\n` as line content. *Rationale:* SSE and header parsing need exact line semantics that survive slice-window boundaries. +- **IO-15** (**MUST**): Skip MUST advance past exactly the requested count, failing with EOF if fewer remain; skip(0) MUST be a no-op even at/after EOF. +- **IO-16** (**SHOULD**): A buffered source SHOULD provide a read-only host-native byte-stream bridge whose single-byte read returns 0–255 or −1 at end and whose bulk read returns the count or −1; closing the bridge closes the owning source. Symmetrically a buffered sink SHOULD provide a writable-stream bridge whose close closes the sink. + +### 5.4 Non-consuming views + +- **IO-19** (**MUST**): A peek MUST return a non-consuming view over the whole remaining source such that reads from it do not advance the original's cursor. *Rationale:* repeatable body reads (logging previews, response replay) require reading the same bytes without disturbing the primary consumer. +- **IO-20** (**MUST**): A slice(offset, count) MUST return a non-consuming, length-bounded view exposing at most `count` bytes starting `offset` ahead of the current cursor; reads from it MUST NOT advance the parent, and reading past the window behaves as end-of-window. +- **IO-21** (**MUST**): Slice offset overflow MUST be detected LAZILY — constructing a slice whose offset exceeds the source size MUST succeed, surfacing only on first read as empty/EOF — while a negative offset or count MUST be rejected eagerly at construction. *Rationale:* callers may slice speculatively before the full body length is known. +- **IO-22** (**MUST**): Closing a slice MUST NOT close its parent or advance the parent's cursor; conversely, closing the parent MUST invalidate every outstanding slice so subsequent reads fail loudly rather than returning stale bytes. **IO-24** (**MUST**): reading from a slice after it has been explicitly closed MUST fail loudly with a state error, distinct from normal EOF. +- **IO-23** (**MUST**): Multiple slices and peeks of one source MUST be mutually independent (each with its own cursor and budget), and a slice-of-a-slice MUST compose offsets additively and cap at the outer slice's remaining bytes. +- **IO-38** (**MUST**): Even though individual instances are single-threaded (**IO-37**), the CLOSE state of a source/buffer MUST be observable across threads to slices derived from it, so a close on one thread reliably invalidates a slice being read on another (no torn or stale reads). + +### 5.5 Pumping and the tee sink + +- **IO-17** (**MUST**): A write-all MUST pump the source to exhaustion into the sink and return the total transferred, terminating only on a −1 read; when pumping a foreign source, a read returning 0 for a non-zero requested count MUST be raised as an I/O error (a source-contract violation), never tolerated as EOF or spun on forever. *Rationale:* a misbehaving foreign source must fail loudly rather than hang or truncate a body. +- **IO-25** (**MUST**): A tee sink MUST mirror written bytes into an in-memory tap AND forward the full, untruncated payload to its primary sink; the wire body MUST never be reduced or altered by the tap. **IO-26** (**MUST**): the tee MUST support a tap capacity limit — once reached, further writes stop copying into the tap while still forwarding the full payload; the default limit is effectively unbounded, and a limit of 0 mirrors nothing while forwarding everything. **IO-27** (**MUST**): the tee MUST mirror attempted bytes into the tap BEFORE forwarding to the primary (so a failed primary write still captures the attempted bytes), and MUST clear its staging buffer even on a failed write so a later write does not prepend stale bytes. **IO-28** (**MUST**): the tee MUST NOT expose a direct backing-buffer handle (attempting it fails, directing callers to the typed write methods), because a raw buffer write would reach only the tap or only the primary and silently corrupt the wire body. **IO-29** (**MUST**): the tee's own flush/close/emit MUST forward to the PRIMARY only, leaving the in-memory tap intact for later snapshotting. + +### 5.6 Provider factories, timeouts, and thread-safety + +- **IO-30** (**MUST**): The provider seam MUST offer factories to create a fresh empty buffer, wrap a caller stream and a byte array as buffered sources, wrap a caller stream as a buffered sink, and wrap a foreign primitive source/sink with the typed surface. Each buffer MUST be fresh, independent, and empty, and the byte-array-wrapping source MUST be an independent copy of the input. *Conformance:* two buffers are distinct and empty; wrapping a byte array then mutating the input leaves the source unchanged. (Provider resolution follows **IO-31**–**IO-36**, the same precedence, idempotence, caching, warning, and de-dup rules as **SEAM-5**–**SEAM-10**.) +- **IO-37** (**MUST**): All streaming instances (source, sink, buffered source/sink, buffer, tee) are single-threaded contracts, NOT required to be safe for concurrent use; callers serialize external access. Independent views MAY be used from different threads, but each individual view remains single-threaded (the close signal of **IO-38** is the one cross-thread-visible exception). +- **IO-39** (**SHOULD**): The provider REGISTRY SHOULD support lock-free reads of the active provider while serializing installs/swaps under a lock that does not pin/park carrier threads under lightweight-thread schedulers. +- **IO-40** (**MUST**): These contracts MUST NOT impose their own read/write timeout — the adapter wraps foreign streams with a no-op timeout, delegating deadlines to the transport — and a mirroring/wrapping sink MUST NOT swallow OR duplicate the wrapped stream's cancellation handling; prompt cancellation of blocked I/O belongs to the transport that owns the real socket, not to this in-memory layer. + +## 6. Request and Response Body Lifecycle + +This section governs how payloads are produced, consumed, replayed, and non-destructively captured for logging. The overriding invariants: never silently emit zero bytes or truncate; never double-consume or double-close; never let logging alter the wire bytes; always bound in-memory capture. + +### 6.1 Request body production and replayability + +- **HTTP-36 / BODY-1** (**MUST**): A request body MUST produce bytes on demand via a single write-to-sink operation, report its media type (nullable) and content length (with −1 meaning unknown), and expose a boolean replayability property defaulting to false (single-use). Replayability MUST be true only when writing more than once yields byte-for-byte identical output. *Rationale:* retry, redirect, and auth-replay query this before deciding whether to buffer or re-send. *Conformance:* a byte-array body is replayable with exact length; a stream-backed body is single-use; an unknown-length body reports −1. +- **BODY-2** (**MUST**): A composite body (e.g. multipart) MUST report replayable iff every part is replayable, and its declared content length MUST collapse to unknown if any part's length is unknown. **HTTP-51** (**SHOULD**): a multipart body SHOULD derive its declared length and its written bytes from one shared framing routine (so length cannot drift from bytes written), generate a spec-valid random boundary, reject a caller boundary violating the RFC 2046 grammar, and MUST quote/escape part-header parameter values so CR/LF or a quote cannot break the framing. +- **BODY-3 / HTTP-37** (**MUST**): A materialize-once operation MUST return the same body unchanged when already replayable, and otherwise drain the body's write output exactly once into an in-memory buffer and return a replayable buffer-backed body, after which the original MUST be treated as consumed. A single-use body MUST fail loudly on a second write (never silently emitting zero bytes), and the consume-once guard MUST be race-safe so that under concurrent writes at most one proceeds and the losers observe a clear error. *Reference implementation: the guard is an atomic compare-and-set.* *Conformance:* write a stream body once then again → second throws; materialize then write twice → both identical; concurrent double-write → exactly one succeeds. +- **BODY-8** (**MUST**): A single-use body that owns a closeable source MUST release that source as part of its single write, so skipping materialization does not leak it. The reference holds this for the buffered-source-backed body (its write drains-and-closes the source); the raw byte-stream-backed bodies do NOT close their stream during write (the rewindable variant keeps it open to replay; the one-shot variant leaves the caller-supplied stream unclosed per its documented ownership). A port MUST decide its stream-ownership rule deliberately rather than assume every single-use body closes its input. +- **BODY-9** (**SHOULD**): A stream-backed body of known length SHOULD be treated as replayable when (and only when) the stream supports mark/reset AND the length fits the platform's maximum single-array bound; otherwise it MUST be single-use, and when replayable each write after the first MUST rewind before reading, with a race-safe rewind (at most one reset between any two writes). +- **HTTP-39 / BODY-10** (**MUST**): An exact-length copy from a source MUST write precisely the declared count; a premature end of stream MUST raise an end-of-file error naming delivered-of-total, a zero-length read for a positive request MUST be a stream-contract violation (never an infinite spin or EOF), and a declared length of 0 MUST be a legitimate empty write. +- **HTTP-40 / BODY-11** (**MUST**): A file-backed body MUST be replayable, open a fresh file handle per write, and validate fail-fast at construction — the file exists, is a regular file, the offset is non-negative and within the size captured at construction, and offset+count does not exceed that size. **BODY-13** (**MUST**): a file transfer MUST detect a short write and raise an error naming transferred-of-total. **BODY-12** (**SHOULD**): a file body SHOULD stream via the platform's most efficient file-to-sink transfer and be recognizable by type so transports can dispatch a true zero-copy kernel path. **BODY-36** (**MAY**): a file body MAY expose a read-only memory-mapped view of its byte range for local hashing/signing without heap copying, rejecting a range larger than a single addressable buffer. +- **HTTP-38 / BODY-35** (**MUST**): Body factories MUST classify replayability by source (byte-array, string, buffer, file, serialized are replayable; a one-shot stream is single-use unless mark/reset applies), a form-urlencoded body MUST be replayable and use `x-www-form-urlencoded` encoding (`+` for space, distinct from RFC 3986 query encoding), and a body's declared content length MUST report the exact count when known and the −1 sentinel otherwise. Ports MUST NOT assume a known length is always present. + +### 6.2 The three re-send gates + +- **BODY-4** (**MUST**): Retry, redirect, and authentication-challenge replay MUST all consult the body's replayability before re-sending a body-bearing request: such a request is eligible only when its body reports replayable, and a consumed single-use body MUST NOT be re-sent on any of these paths. The three paths query the same property but decline differently, and a port need not unify the decline behavior: the retry path stops and surfaces the last outcome, the auth path returns the original challenge response unchanged (and does NOT close it), and the redirect path fails loudly. *Conformance:* drive a retryable 5xx, a preserved-method redirect, and a 401 challenge each with a non-replayable body and assert the documented decline; repeat with a replayable body and assert re-send. +- **BODY-5** (**MUST**): On the retry path specifically, a body-less request's re-send eligibility MUST gate on method idempotency rather than replayability — only idempotent methods are retried when there is no body (a body-less non-idempotent POST is not retried). This gate is retry-specific; the redirect path re-sends body-less requests per redirect semantics and does not consult idempotency. *Conformance:* retry a body-less POST vs a body-less GET against a retryable status; only the GET re-sends. + +### 6.3 Response body single-use and close + +- **HTTP-41 / BODY-14** (**MUST**): A response body MUST be single-use — its read handle is obtained once and, once consumed, the bytes are gone; requesting the handle repeatedly returns the same underlying handle, not a replay. Repeatable/non-destructive access MUST require an explicit buffering wrapper. +- **HTTP-41 / BODY-15** (**MUST**): Closing a response body MUST release the underlying transport connection, MUST be idempotent, and MUST NOT assume the body was read — a caller that skips the body entirely still relies on close to release the connection. **HTTP-43** (**MUST**): a response MUST be closeable, its close idempotent and forwarding to the body, so it can be released in a scoped block whether or not the body was consumed. +- **HTTP-16-body / BODY-16** (**MUST**): Convenience readers that materialize the whole body (as string or byte array) MUST close the body in a finally-style guarantee whether or not the read succeeds. +- **HTTP-42** (**MUST**): Reading a response body as text MUST default its charset to the media type's declared charset, falling back to UTF-8 when none is declared or the declared charset is unknown. + +### 6.4 The lazy parsed-response wrapper + +- **HTTP-44** (**MUST**): A lazy typed-response wrapper MUST expose raw status/headers/protocol/reason/request WITHOUT consuming the body, and MUST parse the typed value at most once on first access, memoizing the outcome so every later access returns the same value or re-throws the same failure without re-running the handler or re-reading the single-use body. Both a null success and a thrown failure MUST be memoized. **HTTP-45** (**MUST**): concurrent first accesses MUST be serialized so the handler runs exactly once, using a lock that cooperates with lightweight/virtual-thread schedulers rather than an intrinsic monitor that pins the carrier across the parse. + +### 6.5 Request-body logging (tee-on-write) + +- **BODY-17** (**MUST**): The request-logging wrapper MUST mirror the exact bytes the wrapped body's single write produces into an internal tap while forwarding those same bytes to the transport sink, consuming the upstream exactly once; the full payload MUST always reach the transport regardless of any tap cap. **BODY-18** (**MUST**): the tap MUST be cleared at the start of every write so a post-write snapshot reflects only the most recent attempt (retries against a replayable delegate MUST NOT accumulate). **BODY-19** (**MUST**): the tap MUST be bounded by a configurable cap; once reached, further bytes stop being copied into the tap while the full payload continues. **BODY-20** (**SHOULD**): if the wrapped write fails partway, the snapshot SHOULD return the bytes mirrored up to the failure. **BODY-21** (**MUST**): the wrapper MUST expose the delegate's replayability verbatim and its materialize-once MUST return a wrapper around the delegate's replayable form (preserving the tap cap). **BODY-37** (**MUST**): the tee MUST NOT expose a direct writable-buffer handle that bypasses the primary sink (restating IO-28 at the body layer). + +### 6.6 Response-body logging (drain-once, two regimes) + +- **BODY-22** (**MUST**): The response-logging wrapper MUST drain the delegate at most once, lazily, on first access (read/snapshot/exception query), buffering up to a configurable byte cap, with concurrent first accesses serialized so the upstream is read exactly once (via a once-latch/mutex that does not pin the carrier thread). +- **BODY-23** (**MUST**): When the whole body fits within the cap (EOF reached before the cap), the wrapper MUST capture it entirely, close the delegate, and thereafter serve every read as a fresh non-consuming view — fully repeatable, with each read succeeding independently. +- **BODY-24** (**MUST**): When the body exceeds the cap, the wrapper MUST buffer only the prefix, leave the delegate open, and serve the next read as a single-use stream that first replays the captured prefix then continues from the still-live tail so the consumer receives the complete body; a second read in this regime MUST fail. +- **BODY-25** (**MUST**): A delegate read returning zero bytes for a positive requested count MUST be a stream-contract violation (error), never end-of-stream; EOF is signaled only by the explicit sentinel. **BODY-26** (**MUST**): a failure during the drain MUST NOT silently truncate — the wrapper retains bytes read before the failure and caches the error such that reads re-throw it every call, snapshot returns the partial bytes without throwing, and an exception-query accessor surfaces the cached error without triggering a drain. +- **BODY-27** (**MUST**): The wrapper MUST close the delegate at most once across all close paths (the wrapper's own close and the one-shot tail's close routing through a single shared close-once guard), because some transport streams throw on double-close; if the delegate's close throws it MUST still be marked closed. **BODY-28** (**MUST**): on the fits-cap path a close failure after a successful full capture MUST NOT be reported as a drain error nor prevent serving the captured body, and the captured in-memory buffer MUST survive the wrapper's close so post-mortem snapshot logging still works. **BODY-29** (**SHOULD**): reported content length SHOULD be the captured size only when fully captured, otherwise the delegate's declared length. + +### 6.7 Bounded error-body buffering and preview + +- **HTTP-52 / BODY-30** (**MUST**): Turning an error response into an exception MUST buffer at most a fixed cap (1 MiB) of the error body into memory and re-serve it as a replayable body, dropping bytes beyond the cap; the buffered copy MUST be readable independently and repeatably after the transport connection is released, and buffering MUST occur inside the original body's close-guaranteeing scope so a provider/buffer-allocation failure still releases the connection. A response with no body MUST be returned unchanged. *Rationale:* an error payload must outlive the connection for inspection, but an adversarial gigabyte body must not exhaust memory. **BODY-31** (**MUST**): error-to-exception mapping MUST apply only to 4xx/5xx; a non-error non-success response (e.g. 304, an unfollowed 3xx) MUST be returned with its body intact. +- **BODY-32** (**MUST**): Byte-capped snapshot/preview operations MUST reject a negative cap, silently clamp the cap to the platform's maximum single-array size, and return whatever bytes are available up to the clamped cap; a capless snapshot MUST fail loudly when the captured size exceeds the platform maximum rather than attempt an impossible allocation. **BODY-33** (**SHOULD**): an exception-side error-body preview SHOULD be non-consuming, reading from a fresh peek view and returning null when there is no body and empty when exhausted. +- **BODY-34** (**MUST**): Body logging on both sides MUST engage only when body-level logging is enabled, and the in-memory capture on both sides MUST be bounded by one shared preview-size configuration; the consumer MUST still receive every byte of an over-preview body — only the logged preview and size fields are bounded. + +## 7. Execution Context Model + +A single in-flight call's correlation state is modeled as a one-way promotion chain of three immutable context flavors, each carrying a shared instrumentation bundle and a single call-unique key, and each registered in a bounded process-wide store keyed by that call key. + +### 7.1 The promotion chain + +- **CTX-1** (**MUST**): The model MUST provide three context flavors forming a one-way promotion chain mirroring the call lifecycle — a dispatch stage (before any request), a request stage (an outgoing request assembled), and an exchange stage (a response arrived). Promotion advances dispatch → request → exchange only; there is no reverse promotion and the exchange stage is terminal. *Conformance:* each stage exposes exactly its expected artifacts; the exchange type exposes no method promoting back. +- **CTX-2** (**MUST**): Each promotion MUST be additive and non-mutating — producing a NEW instance, never modifying the source — carrying forward the same instrumentation bundle reference and the same call key (and, for request→exchange, the request and operation name), and adding exactly one artifact: the request when promoting dispatch→request, the response when promoting request→exchange. The operation name is introduced at the request stage as an argument to the dispatch→request promotion. *Conformance:* promote and assert the carried-forward fields are identical instances and each source is unchanged. +- **CTX-3** (**MUST**): The whole chain MUST share ONE call key: a promotion carries the source's call key forward verbatim, so all three flavors register under the identical store slot and successive promotions overwrite one entry. + +### 7.2 Call-key uniqueness + +- **CTX-4** (**MUST**): Each call's store key MUST be unique per call and MUST NOT be derived from the trace identifier — or the trace+span pair — alone, so two concurrent calls sharing a trace id (and even a span id) receive distinct keys and never evict each other. *Rationale:* trace ids are not call-unique — a disabled-tracing context shares one constant trace id across every untraced call, an inbound distributed trace shares one across many spans, and a tracer may reuse a span id. *Conformance:* two contexts with identical trace AND span id have differing keys and both register. *Reference implementation: the default key appends a process-wide monotonic counter to a `traceId:spanId` rendering.* +- **CTX-5 / CTX-6** (**MUST**): A directly-constructed (off-chain) context without an explicit key MUST receive a fresh call-unique key using the same uniqueness guarantee, and default construction MUST mint globally distinct keys across the whole process and all three flavors. A consequence — because the key participates in value-equality — is that two default-constructed contexts with otherwise identical fields are NOT equal; callers needing value-equality MUST be able to pin an explicit shared key. +- **CTX-17** (**MUST**): Registration MUST happen at promotion time, not head-context construction: constructing the initial dispatch context MUST NOT auto-register it. The first store entry is installed by the first promotion, so a dispatch context never promoted leaves no store entry and its close is a harmless no-op. + +### 7.3 The context store + +- **CTX-7** (**MUST**): Contexts MUST be immutable and shareable without external synchronization, and the store MUST be thread-safe such that contexts with distinct call keys can be registered, overwritten, and removed concurrently without external locking. +- **CTX-8** (**MUST**): The store MUST support unconditional overwrite (install-or-replace, never throwing) used by promotion, and a reject-on-duplicate insert (install only if absent) that admits exactly one winner under concurrency and fails all others with an error naming the key. +- **CTX-9** (**MUST**): Closing a context MUST evict the store entry CONDITIONALLY ON REFERENCE IDENTITY — removing the slot only when the current occupant IS the closing context, never by value equality — and removing a non-existent or already-replaced slot MUST be a well-defined no-op. *Rationale:* contexts are value-equal, so a value-equality remove could let a stale context evict a structurally-identical live sibling. **CTX-10** (**MUST**): only the context currently occupying the shared slot (the furthest-reached link) evicts on close; closing an intermediate link that was promoted is a no-op. +- **CTX-18** (**MUST**): Looking up an unknown key MUST return an explicit absent result, never throw, and removing an unknown or already-removed key MUST be a no-op, so double-close and cleanup-path closes are well-defined. + +### 7.4 Bounded backstop + +- **CTX-11** (**MUST**): The store MUST be bounded — enforcing a maximum number of tracked entries and, after each insert, draining back to at or below that cap — as a backstop so a caller who fails to close a context on an exception path leaks at most the cap's worth of entries. *Rationale:* a registered context strongly pins the full request+response graph (possibly an unread body holding a connection). **CTX-19** (**MUST**): the store MUST keep that graph reachable while the context stays in the store; reimplementations MUST NOT hold contexts by weak/soft references (that would let an in-flight context be collected mid-call) and MUST treat the bounded cap, not garbage collection, as the leak backstop. +- **CTX-12** (**SHOULD**): The cap-draining strategy SHOULD be a post-insert drain LOOP (drain until at or under the cap) rather than a single check-then-evict, so concurrent insert bursts converge to the bound instead of overshooting. **CTX-13** (**MAY**): eviction victim selection is arbitrary — the store provides NO ordering and NO guarantee that any particular entry survives an insert that trips the cap, including the just-inserted entry; the only retention guarantee is that after inserts quiesce the live set is at or below the cap. A port MUST NOT rely on any specific entry surviving. + +### 7.5 Instrumentation bundle and operation name + +- **CTX-14** (**MUST**): Each context MUST carry a correlation/instrumentation bundle exposing at minimum a trace id, a span id, trace flags, trace state, a trace-id encoding flavor, validity and remoteness flags, an active span, and a per-operation tracer factory, W3C Trace Context compatible for cross-service propagation. **CTX-15** (**MUST**): a disabled-tracing / no-op bundle MUST be available as the default with reserved invalid sentinels (all-zero trace id, all-zero span id, zero flags, empty state), isValid false, isRemote false, a no-op span and no-op tracer factory; because it shares constant identifiers across every untraced call, the call-key derivation (CTX-4) MUST remain call-unique even when every bundle field is identical. +- **CTX-16** (**SHOULD**): A context SHOULD carry an optional operation name (a schema-defined operation id, or absent), MUST carry it forward unchanged across every promotion, and MUST keep it advisory only — exposed to the tracing seam to label the operation, never influencing the request, dispatch decision, or store key. **CTX-20** (**SHOULD**): the per-operation tracer factory SHOULD default to a no-op emitting nothing so untraced call sites pay zero tracing cost, and its factory method MUST be safe to invoke concurrently. + +## 8. Execution Pipelines + +The SDK has two cooperating pipeline layers. The **stage-based pipeline** (§8.1) is the user-facing dispatch runtime: cross-cutting concerns become discrete bidirectional steps assigned to a fixed, totally-ordered list of named stages, and a request flows inbound to a terminal transport and back outbound in reverse. The **recovery-chain primitives** (§8.2) are the resilience layer beneath resilience steps: a closed two-variant outcome threaded through a fold so every failure — pre-request, transport, or post-response — is observed uniformly. §8.3 gives explicit guidance on when to use which. The two layers are parallel and cooperate, and they share one backoff calculator and one pacing-header parser so their retry behavior cannot drift. + +### 8.1 The stage-based pipeline + +**Ordering and stages.** + +- **PIPE-1** (**MUST**): Steps MUST execute in a single fixed total order derived from stage assignment: a step in a lower-ordered stage runs before (wraps) a step in a higher-ordered stage on the inbound path and observes the response later on the outbound path. This CROSS-stage order is deterministic and independent of insertion order; the order of steps WITHIN one non-pillar stage follows insertion (**PIPE-7**). *Conformance:* one probe step per stage records entry/exit; entry matches the stage list and exit is its exact reverse, regardless of insertion order. +- **PIPE-2** (**MUST**): The runtime MUST preserve the pillar precedence chain REDIRECT → RETRY → AUTH → LOGGING → SERDE (outer to inner), plus an outermost pre-redirect slot outside both loops and a terminal SEND hop innermost. A step's placement relative to these boundaries determines whether it sees per-hop/per-attempt responses or only the single terminal response. (SERDE is a reserved slot with no shipped behavior.) *Conformance:* with a redirecting+retrying transport, a pre-redirect step is invoked once with the final response, and an auth step re-runs per redirect hop. +- **PIPE-3** (**SHOULD**): The stage list SHOULD interleave user-extensible slots around each pillar (a "pre" and "post" slot) and SHOULD use sparse numeric order keys so new stages can be inserted without renumbering. +- **PIPE-4** (**MUST**): A pillar stage MUST admit at most one step; the configurable pillars are REDIRECT, RETRY, AUTH, LOGGING, and SERDE. **PIPE-5** (**MUST**): installing a DISTINCT second step onto an occupied pillar — via any add or a bulk reload — MUST fail fast naming both step types and pointing at the replace path, rather than silently overwriting. **PIPE-6** (**MUST**): re-installing the SAME step onto its pillar MUST be idempotent, distinguished by reference identity, not value equality. **PIPE-8** (**MUST**): the terminal SEND stage MUST be reserved for the transport hop, MUST NOT hold a user step, and flattening MUST skip it. + +**The per-call cursor.** + +- **PIPE-9** (**MUST**): An empty pipeline MUST dispatch directly to the terminal transport, threading the caller's per-call options, and SHOULD do so without allocating per-call cursor state. **PIPE-10** (**MUST**): the built runtime MUST be immutable after construction, and each send MUST allocate its own per-call cursor so concurrent calls share no mutable pipeline state. **PIPE-11** (**MUST**): steps MUST be safe for concurrent invocation, with per-request mutable state living in the per-call cursor, never on the step. +- **PIPE-12** (**MUST**): Each step MUST be bidirectional — receiving the inbound request, MAY invoke the rest of the chain, and MAY inspect or substitute the outbound response — and MAY short-circuit by returning a synthetic response without invoking the chain. **PIPE-13** (**MUST**): invoking the next step MUST advance a monotonic cursor and invoke it; when exhausted it MUST dispatch the current in-flight request to the terminal transport, threading the caller's per-call options. The cursor MUST only move forward within a single un-forked drive. **PIPE-14** (**MUST**): a substituted request MUST propagate to every downstream step and the terminal dispatch (it "sticks"). +- **PIPE-15** (**MUST**): A step that drives the downstream chain MORE THAN ONCE (retry re-attempting, redirect following a hop, auth retrying after a challenge) MUST fork a fresh cursor for each re-drive rather than reusing the same next handle; reusing the handle resumes past already-visited steps and MUST be treated as a defect. A port MUST provide an equivalent fork primitive and its wrapping pillar steps MUST use it. **PIPE-16** (**MUST**): a forked cursor MUST resume from the SAME position as its parent, carry the current in-flight request, and share the immutable options, with forks advancing independently. *Conformance:* a retry step re-driving twice via the fork visits every downstream step on both attempts; reusing the handle skips downstream steps on the second attempt. +- **PIPE-17** (**MUST**): The caller's per-call options MUST be carried unchanged for the entire call, including across every re-drive fork, readable by any step, and threaded into the terminal dispatch; options MUST be immutable/shared, not copied-and-diverged per fork. +- **PIPE-40** (**MUST**): A wrapping step that re-drives the chain MUST release each superseded intermediate response — closing its body before the next drive — and MUST NOT close the response it ultimately hands back to the caller (close-responsibility passes outward); on paths that abandon a re-drive (redirect cycle, non-replayable body, budget exhausted) the in-flight response MUST be returned unclosed. *Rationale:* the response-lifecycle corollary of PIPE-15 and a frequent porter trap. *Conformance:* a 2-hop redirect closes each intermediate body exactly once while the final response reaches the caller open. + +**Surgical edits and build.** + +- **PIPE-7** (**MUST**): Non-pillar stages MUST hold an ordered sequence — append adds to the tail, prepend to the head — preserving relative order through build and any re-bucketing edit. **PIPE-18** / **PIPE-19** (**MUST**): the surgical insert-after/insert-before and replace edits MUST act relative to the FIRST existing instance of an anchor type, and the inserted/replacing step MUST declare the same stage as the anchor; a cross-stage insert/replace MUST be rejected. **PIPE-20** (**MUST**): remove MUST delete EVERY instance of a type, preserving relative order, and be a no-op when absent. **PIPE-21** (**MUST**): an insert-relative or replace whose anchor type is absent MUST fail identifying the missing type. +- **PIPE-22** (**MUST**): Every mutation that re-buckets by stage MUST re-derive the flattened order deterministically, so the observable ordering after an edit equals building the same set from scratch. **PIPE-23** (**MUST**): a bulk reload MUST be all-or-nothing — a pillar collision leaves the existing collection completely unchanged rather than a partial rebuild. **PIPE-24** (**MUST**): the standard-resilience preset MUST install into EMPTY slots only, validating up front that no target pillar is occupied and rejecting the whole call (installing nothing) if any is. +- **PIPE-25** (**MUST**): `build()` MUST produce the ordered sequence by flattening stages in declaration order (skipping SEND) into an immutable runtime that exposes a read-only, ordered view of its steps. **PIPE-38** (**MUST**): append-all MUST preserve the batch's iteration order within a stage, while prepend-all (each element prepended individually) results in the REVERSED batch order; a port MUST document this asymmetry. **PIPE-36** (**SHOULD**): the shipped pillar families SHOULD lock their stage assignment so a subclass cannot relocate out of its pillar. **PIPE-37** (**MUST**): a step whose correctness depends on the SINGLE terminal response (e.g. status→typed-error mapping) MUST occupy the outermost pre-redirect slot so it runs outside both loops, and on a non-error status MUST return the response untouched. + +**Self-conformance and lifecycle.** + +- **PIPE-26** (**MUST**): The runtime MUST itself implement the transport SPI, delegating execute/execute-async to its own send/send-async (with and without options), so a configured pipeline can stand in wherever a transport is expected and options survive the indirection. **PIPE-27** (**MUST**): closing the pipeline MUST be a no-op with respect to the underlying transport — the pipeline never owns its transport and MUST NOT close it. +- **PIPE-39** (**SHOULD**): The runtime SHOULD offer convenience constructors for a step-less pipeline forwarding directly to a transport and a standard pipeline installing the default resilience pillars (sync: redirect+retry+instrumentation; async: retry+instrumentation with a caller-supplied scheduler for non-blocking backoff). + +**The async mirror.** + +- **PIPE-28** (**MUST**): The async runtime MUST reuse the identical stage identities and staging policy as the sync runtime; the two MUST NOT each re-derive ordering independently. **PIPE-29** (**MUST**): an async step MUST NOT throw synchronously to signal a transport/async failure — it MUST return a future completing exceptionally — and MAY throw synchronously only for caller-bug argument validation. **PIPE-30** (**MUST**): the async runtime MUST defensively normalize ANY synchronous exception from a step's async entry point (or the empty-pipeline dispatch) into an exceptionally-completed future, while fatal/unrecoverable errors propagate synchronously and MUST NOT be swallowed. +- **PIPE-31** (**MUST**): The async terminal response-mapping operator MUST, on success, apply the handler then close the response (tolerating idempotent double-close); on failure it MUST unwrap async-wrapper exceptions to the original cause and MUST close any response accompanying a failure to avoid leaking the body. **PIPE-32** (**MUST**): the async standard pipeline MUST NOT follow HTTP redirects at the pipeline layer (there is no async redirect pillar) — a 3xx surfaces verbatim unless redirect following is enabled on the transport — and a port MUST document this asymmetry with the sync standard pipeline. + +**Bridges.** + +- **PIPE-33** (**MUST**): The sync-to-async bridge MUST require a caller-supplied executor (no default), run the wrapped synchronous pipeline as a single opaque unit on that executor (so its steps stay synchronous on the worker and do NOT gain per-step concurrency), and thread per-call options into the wrapped send; cancelling the future with interruption MUST interrupt the worker running the in-flight send, and cancelling without interruption MUST complete as cancelled without interrupting. **PIPE-34** (**MUST**): the async-to-sync bridge MUST block on the async result per call while preserving options and MUST honor thread interruption — on interrupt restoring the flag, cancelling the in-flight future, and surfacing an interrupted-I/O error. +- **PIPE-35** (**SHOULD**): The builder SHOULD provide two unambiguous ways to seed from an existing pipeline — FLATTEN (copy its steps and transport so they run in the same loops) versus NEST (treat it as an opaque transport so the new steps run once, OUTSIDE the nested loops) — and a port MUST make the flatten-vs-nest choice explicit rather than accidental. + +### 8.2 The recovery-chain primitives + +- **RECOV-1** (**MUST**): The response-side outcome MUST be a closed sum type with exactly two variants — a success carrying a response and a failure carrying a throwable — mutually exclusive and jointly exhaustive, with derivable accessors and a fold that applies exactly one of two branches at most once per call. *Rationale:* a closed two-variant outcome is what lets one code path handle a throwable and a response identically. +- **RECOV-2** (**MUST**): The unified orchestrator MUST catch EVERY throwable from any request-chain step and from the transport invocation, convert it into a Failure, and thread it through the response recovery chain; no throwable from the pre-request phase or the transport may bypass the recovery hooks. This is the defining invariant: a before-request throw MUST NOT skip after-error handling. *Conformance:* a throwing request step and a throwing transport each surface as a Failure to a recovery hook. +- **RECOV-3** (**MUST**): The request recovery chain MUST apply its ordered steps as a sequential left-to-right fold (output of step N is input of step N+1); an empty chain returns the input unchanged; a throwing step aborts the remainder and propagates (the orchestrator converts it per RECOV-2). +- **RECOV-4** (**MUST**): Response steps (response→response) MUST run ONLY when the current outcome is a Success; on a Failure the entire response-step phase is skipped. **RECOV-5** (**MUST**): recovery steps MUST be applied to EVERY outcome — successes and failures — sequentially, always, observing the terminal outcome including a failure a response step just produced by throwing. **RECOV-6** (**MUST**): the fold order MUST be all response steps first (on the success path), then all recovery steps, in declared order within each group. +- **RECOV-7** (**MUST**): If a response step throws, its throwable MUST be converted into a Failure fed to the subsequent recovery steps — never propagated out of the response chain — so error-mapping steps (status→typed exception) flow through recovery exactly like a transport error. **RECOV-8** (**MUST**): if a recovery step throws, its throwable MUST be wrapped into a Failure fed to the NEXT recovery step, never aborting the remaining recovery steps, and the chain's apply operation MUST NOT throw under any input. **RECOV-9** (**SHOULD**): recovery steps SHOULD surface errors by returning a Failure rather than throwing. +- **RECOV-10** (**MUST**): The orchestrator's dispatch MUST unwrap the final outcome as: on Success return the contained response; on Failure rethrow the contained throwable UNCHANGED (no wrapping, no substitution). Any typed-exception surfacing must be done by a recovery step constructing the error and returning a Failure. **RECOV-11** (**MUST**): when wrapping a cancellation/interruption throwable into a Failure, the wrapping helper MUST re-assert the cancellation signal on the current context before returning, so code later blocked on the outcome still observes the cancellation. +- **RECOV-12** (**MUST**): When a response or recovery step THROWS while holding a Success response, the pipeline MUST close/release that in-hand response before wrapping the throwable, attaching any close error as suppressed so it never masks the primary, releasing the response exactly once. **RECOV-13** (**MUST**): when a step handed a Success deliberately RETURNS a different outcome (a Success→Failure transform, or a substitute Success), the pipeline MUST NOT auto-close the discarded original — the transforming step OWNS releasing the response it drops. +- **RECOV-14** (**MUST**): A chain's step lists MUST behave as immutable after construction; the response recovery chain MUST defensively copy BOTH its lists at construction. (Reference-implementation asymmetry a porter must not assume away: the request recovery chain does NOT copy — it retains the caller's read-only list reference directly — so a port SHOULD copy there too.) Steps MUST be safe for concurrent invocation, with per-request state in the passed context or the value being transformed, never on the step. +- **RECOV-15 / RECOV-16** (**MUST**): The status→typed-exception mapping step MUST treat only 400..599 as errors (mapping to the matching typed exception, which becomes a Failure per RECOV-7) and return all other statuses unchanged, and before mapping an error-status response (both here and on a re-sent error response) the error body MUST be buffered into a bounded (1 MiB), replayable in-memory copy so the connection is released promptly and the body remains readable on the Failure, with the same bound shared across all buffering paths and the cap a hard truncation with no marker. *Conformance:* 400..599 produce a Failure with a typed exception; a sub-cap error body survives whole while an over-cap body is truncated to the cap with the connection released. + +### 8.3 Choosing between the two layers + +Use the **stage-based pipeline** (§8.1) as the composition surface for assembling a client: it is where redirect, retry, auth, logging/instrumentation, and serialization concerns are ordered as pillar steps, where per-call cursors and forks drive re-attempts, and where a configured pipeline becomes a transport others can nest. It has a real async mirror. + +Use the **recovery-chain primitives** (§8.2) as the resilience layer those steps are built on when a concern must observe *every* outcome uniformly — in particular any error-mapping, retry, or rescue logic that must see a transport failure and a response failure through one code path, and must never let a pre-transport throw bypass it. The recovery layer is synchronous; its async equivalent is expressed through the stage-based async pipeline. A port MUST NOT collapse the two layers into one: the stage pipeline owns ordering and re-drive-with-fork; the recovery chain owns the sum-type fold and the uniform-failure guarantee. The two intentionally differ in one place — the recovery-aware retry stack enforces a total-timeout budget the stage-based retry step omits (see §9, **RETRY-27**, **RETRY-28**) — and a port unifying retry entry points MUST make that budget explicitly opt-in. + +## 9. Retry and Resilience + +Retry is automatic, safety-gated re-execution of a failed exchange. The SDK ships two cooperating stacks — the recovery-chain retry (with a total-timeout budget) and the stage-based retry step — deliberately built on ONE status classifier, ONE backoff calculator, ONE pacing-header parser, and ONE set of tuning constants so behavior cannot drift. A port should preserve the single-sourcing, the two-axis eligibility model, interrupt-safe backoff, and the non-blocking async trampoline. + +### 9.1 The two independent axes + +Retry happens only when BOTH a retryable condition and a re-sendable request hold; the axes are orthogonal. + +- **RETRY-1** (**MUST**): The retryable-status classifier MUST be single-sourced and treat exactly 408, 429, and all of 500–599 EXCEPT 501 and 505 as retryable. This classifier is the single definition the response-carrying exception flag and the stage stack's default predicate derive from; the recovery stack layers its own configurable status allow-list on top (**RETRY-37**). *Rationale:* 501/505 mean the server cannot fulfill the request regardless of retry. +- **RETRY-2** (**MUST**): The retryable-throwable set MUST be defined in exactly one place: any throwable that is, or has anywhere in its cause chain, an I/O error or a timeout error, with an iterative, identity-tracking cause-chain walk that terminates on a cyclic chain. **RETRY-3** (**MUST**): a response-carrying exception MUST derive its own retryable flag from the single status classifier at construction, not a hardcoded per-subclass constant. **RETRY-4** (**MUST**): a transport-level failure that produced no complete response (connection refused, TLS/DNS failure, socket read timeout, peer reset) MUST be classified retryable unconditionally at the condition level (safety is gated separately). +- **RETRY-5** (**MUST**): A request is re-sendable iff it has no body AND its method is idempotent, OR it has a body AND that body is replayable; both stacks MUST apply this identical rule. **RETRY-6** (**MUST**): the idempotent-method set MUST be single-sourced and equal to `{GET, HEAD, OPTIONS, PUT, DELETE}`; POST/PATCH are re-sendable only via the replayable-body path. +- **RETRY-7** (**MUST**): When a request is not re-sendable, the retry logic MUST perform exactly one attempt and MUST NOT retry — even when the condition is retryable and even when there is no body to physically re-send (a bare non-idempotent POST). **RETRY-8** (**MUST**): retry eligibility MUST require BOTH a retryable condition AND a re-sendable request; neither implies the other. + +### 9.2 Backoff and pacing + +- **RETRY-9** (**MUST**): The unjittered exponential delay MUST be `initialDelay × multiplier^(attempt−1)`, attempt 1-indexed (attempt 1 is the wait before the first retry), clamped to a maximum delay cap. **RETRY-10** (**MUST**): symmetric jitter MUST draw the effective delay uniformly from `[d×(1−j/2), d×(1+j/2)]` with midpoint `d`, `j=0` returning `d`, `j` constrained to `[0,1]`, a degenerate sub-nanosecond range returning the base delay, and a negative sample floored to zero. **RETRY-11** (**MUST**): delay computation MUST be overflow-safe (saturate to the cap rather than throw) and MUST reject attempt < 1. **RETRY-12** (**SHOULD**): defaults SHOULD be initial delay 200 ms, multiplier 2.0, max delay 8 s, jitter 0.2, and a budget of 3 sends. +- **RETRY-15** (**MUST**): The pacing-header parser MUST recognize `Retry-After` as delta-seconds (integer and fractional), `Retry-After` as an RFC 1123 HTTP-date (tolerant of an informational weekday and single-digit day), `retry-after-ms` and `x-ms-retry-after-ms` as integer milliseconds, and `X-RateLimit-Reset` as Unix epoch seconds whose delta is positively jittered to `[100%,120%]`. **RETRY-16** (**MUST**): the parser MUST be total (never throw); malformed/negative/out-of-range values MUST map to "no hint" (null), NOT a zero delay, so the caller falls back to backoff rather than hammering the server. **RETRY-17** (**MUST**): a valid HTTP-date/epoch already in the past MUST yield a zero delay (retry immediately), distinct from unparseable → no hint. **RETRY-18** (**MUST**): any computed pacing delta MUST be clamped to a finite ceiling (365 days) before nanosecond conversion. **RETRY-19** (**MUST**): numeric `Retry-After` parsing MUST be screened by a strict decimal grammar before any float parse, rejecting type-suffixed, hex-float, NaN, and Infinity forms. +- **RETRY-20** (**MUST**): A present pacing hint MUST override (replace, not augment) the exponential schedule for that single decision; a literal `Retry-After` hint MUST NOT receive additional symmetric jitter, and where a total-timeout deadline applies the hint MUST still be clamped against it. **RETRY-21** (**MUST**): pacing resolution MUST honor a defined precedence and return the first parseable value — the recovery stack scans the whole header map with a fixed precedence (`Retry-After` numeric then date → `retry-after-ms` → `x-ms-retry-after-ms` → `X-RateLimit-Reset`); the stage stack walks a caller-configurable ordered header list. **RETRY-22** (**MUST**): a failure while parsing a pacing header MUST NOT mask the real upstream failure — the loop falls back to exponential backoff and the original throwable remains the surfaced error. + +### 9.3 Cancellation, timeout, and the wait + +- **RETRY-23** (**MUST**): Thread interruption / cancellation MUST never be treated as a retryable failure. On interrupt during a blocking backoff wait, the implementation MUST restore the cancellation flag, cancel any externally-scheduled wake, abort the retry loop, and surface an interrupted-I/O error; a downstream interrupt surfaced as an interrupted-I/O error is treated as terminal cancellation, not retried. **RETRY-24** (**MUST**): a read-timeout represented as a subtype of the interrupted-I/O error MUST NOT be mistaken for cancellation — it remains a retryable condition. **RETRY-25** (**MUST**): non-recoverable runtime errors (out-of-memory, stack overflow) MUST NOT be retried, classified retryable, or logged; they MUST be surfaced unchanged with no suppressed-trail attachment. +- **RETRY-26** (**MUST**): The inter-attempt wait MUST be cancellable/interruptible and MUST NOT pin an execution carrier for its duration — a naive uninterruptible sleep that cannot be cancelled is non-conforming. *Reference implementation: the recovery stack schedules the wake on a shared scheduler and blocks on the resulting future; the stage-sync stack performs an interruptible sleep that unmounts a virtual-thread carrier; async implementations schedule the delay without blocking a thread.* + +### 9.4 Budgets, classification, and the two-stack reconciliation + +- **RETRY-27** (**MUST**): The recovery stack MUST enforce an optional total-timeout budget with per-attempt deadline shrinking — before each attempt aborting if the attempt cap is reached, if elapsed ≥ budget, or if elapsed + next-delay would exceed the budget — clamping the delay so it cannot overshoot, with a zero budget disabling the deadline. **RETRY-28** (**MUST**): the stage-based stack MUST NOT impose a total-timeout budget; a port that unifies the stacks MUST make the total-timeout an explicitly opt-in feature rather than always-on. +- **RETRY-13** / **RETRY-14** (**MUST**): Both stacks MUST compute backoff via the one shared calculator and constants, and their attempt budgets MUST denote the same number of total wire sends under equivalent defaults (the recovery stack's max-attempts, default 3, equals the stage stack's max-retries, default 2, plus one initial send). +- **RETRY-37** (**MUST**): In the recovery stack, for a failure carrying a received response the configured retryable-status set MUST be authoritative — it can both widen and narrow relative to the built-in classifier — while a no-response transport failure falls back to its always-retryable flag. (The code is authoritative-contains, not an intersection with the baked flag; a port should follow that.) **RETRY-36** (**MUST**): a re-sent response whose error status is in the configured set MUST be re-mapped into a typed failure (with its body buffered per RETRY-35/RECOV-16) so the loop keeps evaluating the budget (a 503,503,200 sequence reaches the 200); all other re-sent responses pass through as Success. +- **RETRY-35** (**MUST**): A retryable response's body/connection MUST be released before the backoff wait so a socket is not pinned across the delay; the pacing delay is computed from the still-open response first, and if the retry decision or delay computation throws, the response MUST still be closed before propagating. **RETRY-34** (**MUST**): on terminal failure, every prior failed attempt's exception MUST be attached to the surfaced exception as suppressed, skipping the surfaced instance itself so a reused exception instance cannot trip a self-suppression error; on eventual success the prior trail MUST be discarded. (Reference note: only the async stack currently implements the skip-self guard; a port MUST apply it to both stacks.) + +### 9.5 The async trampoline and configurable knobs + +- **RETRY-30** (**MUST**): The asynchronous retry loop MUST be driven by an iterative trampoline — N retries MUST NOT build an N-deep chain of future continuations or stack frames; a completion warranting another attempt hands control to a single active pump via a re-arm flag rather than recursing. **RETRY-31** (**MUST**): async backoff delays MUST be scheduled non-blockingly, a zero-length delay completing inline and re-arming the active pump. **RETRY-32** (**MUST**): if the caller has already completed or cancelled the returned async result, the driver MUST launch no further attempts, and any response arriving from an in-flight attempt MUST be closed rather than leaked. **RETRY-33** (**MUST**): every terminal path of the async loop MUST complete the returned future (a throwing predicate, delay computation, log call, or synchronous scheduler rejection each completing it exceptionally), closing any open retryable response first. +- **RETRY-39** (**MUST**): The stage stack's delay resolution MUST follow the precedence caller delay-override → server pacing headers (response path only) → fixed delay → exponential backoff, the exception path skipping the header step. **RETRY-40** (**SHOULD**): a throwing user delay-override SHOULD be non-fatal (log and fall back), while a throwing should-retry predicate SHOULD abort the call as a well-typed error, with fatal errors rethrown unchanged in both. **RETRY-41** (**MUST**): the stage stack MUST resolve the effective retry count as present-override-wins (validated non-negative), else the configured value, with a negative configured value clamped to the default and zero meaning "no retries". **RETRY-42** (**MUST**): all retry policy components MUST be immutable and stateless after construction and safe for concurrent invocation, with every piece of per-call state on the per-call stack/driver, never the shared instance. **RETRY-43** (**MAY**): a fixed-delay configuration MAY force a flat delay disabling backoff and jitter (making the backoff path unreachable). **RETRY-44** (**MUST**): each attempt MUST re-execute the downstream chain with fresh per-attempt continuation state rather than reusing the prior attempt's in-flight chain, and upstream steps MUST NOT mutate the shared in-flight request between attempts. **RETRY-45** (**MUST NOT**): the retry engine MUST NOT shut down or close a caller-supplied scheduler; a process-wide default scheduler, when used, is likewise never shut down by the SDK. **RETRY-29** (**MAY**): an opt-in server-driven override MAY let a response header force or suppress the retry classification, flipping only classification and remaining subject to the attempt cap and the re-send-safety gate. **RETRY-38** (**SHOULD**): an optional per-attempt request header MAY stamp the 1-based attempt ordinal on a fresh per-attempt copy, never mutating the captured template and preserving any idempotency key, allocating nothing when disabled. + +## 10. Redirect Handling + +Redirect following is a synchronous pillar step coordinating with the auth pillar via an internal cross-origin marker. The async pipeline follows no redirects (§8.1, **PIPE-32**; **REDIR-25**). + +### 10.1 Which codes are followed and how method/body change + +- **REDIR-1** (**MUST**): A redirect is attempted only for 301, 302, 303, 307, and 308; any other status (including 2xx, 4xx, 5xx, and non-redirect 3xx) is returned verbatim without consulting redirect logic. **REDIR-2** (**MUST**): 300, 304, and 305 MUST NOT be auto-followed even with a Location header (305 in particular must never redirect to a server-chosen proxy). +- **REDIR-3** (**MUST**): For 301 and 302, a redirect is followed only if the ORIGINAL request method is in the configured allowed-method set (default `{GET, HEAD}`), and when followed the original method AND body are preserved — there is deliberately NO automatic POST→GET rewrite. **REDIR-4** (**MUST**): 307 and 308 preserve method and body, followed only if the method is allowed. **REDIR-5** (**MUST**): 303 is NOT followed by default; when opted in it is re-issued as a GET with the body dropped and every `Content-*` request header (case-insensitively) removed, regardless of the original method. +- **REDIR-6** (**MUST**): Any followed method-preserving redirect (301/302/307/308) re-sends the original body, so it MUST be replayable; if present and not replayable the operation MUST fail with a clear error naming replayability rather than corrupting or truncating the re-send, and the redirect is not attempted (303 is exempt because it drops the body). + +### 10.2 Credential hygiene and cross-origin + +- **REDIR-7** (**MUST**): The Authorization header MUST be stripped before EVERY redirect re-issue — including same-origin and the 303 GET rebuild — because re-attaching a credential for a known origin is the auth layer's job. **REDIR-8** (**MUST**): a redirect is cross-origin iff the resolved target differs from the ORIGINAL (seed) request origin in scheme, host (case-insensitive), or effective port (scheme default when omitted); the comparison MUST be against the seed origin, not the previous hop, so a same-origin sub-redirect on a foreign host cannot re-expose the credential. **REDIR-9** (**MUST**): on a cross-origin redirect (method-preserving or 303 GET rebuild) the origin-scoped Cookie and Proxy-Authorization headers MUST also be stripped. **REDIR-10** (**SHOULD**): on a same-origin redirect the Cookie header SHOULD be retained (only Authorization is stripped same-origin); a more conservative port MAY strip all cookies. +- **REDIR-11** (**MUST**): Because the auth layer runs INSIDE the redirect loop, a cross-origin re-issue MUST carry an out-of-band signal instructing the auth layer to skip credential stamping. This signal MUST (a) be impossible for a server-supplied Location to forge into a leak — the redirect layer clears any inbound copy on EVERY re-issue before conditionally setting its own on a cross-origin hop; (b) only SUPPRESS stamping, never CAUSE a credential to be sent; (c) be removed by the credential-attaching layer before dispatch. A same-origin re-issue is not signaled and is re-stamped normally. *Reference/porter caveat: only the auth step strips the marker, so a pipeline with no auth step (including the sync standard-resilience preset) forwards the internal marker to the transport; a robust port should strip the signal independently of whether a credential layer runs.* **REDIR-24** (**MUST**): the redirect follower MUST wrap the auth layer (redirect outer, auth inside, per hop), which is what necessitates REDIR-7 plus REDIR-11. +- **REDIR-12** (**MUST**): Userinfo in the Location target (`user:pass@`) MUST be dropped before re-issue; server-supplied embedded credentials MUST never be used. **REDIR-13** (**MUST**): stripping userinfo and resolving the Location generally MUST preserve the wire-exact, already-percent-encoded path, query, and fragment and MUST preserve bracketed IPv6 literal hosts and explicit ports; re-encoding that would decode `%2F`→`/` or `%26`→`&` is forbidden. + +### 10.3 Resolution, loops, hops, and lifecycle + +- **REDIR-14** (**MUST**): A relative Location MUST be resolved against the CURRENT hop's request URL per RFC 3986; absolute values are used as-is after userinfo stripping. **REDIR-15** (**MUST**): an HTTPS→HTTP scheme downgrade across a single hop MUST be rejected by default (fail with a clear error), permitted only via an opt-in that surfaces the downgrade observably; credential stripping applies regardless, and the check is evaluated per hop. +- **REDIR-16** (**MUST**): The step MUST detect redirect loops by recording every visited absolute URI (seeded with the original request URI) and, when a redirect would revisit a seen URI, stop and return the CURRENT redirect response WITHOUT throwing, leaving its body open for the caller. **REDIR-17** (**MUST**): the number of followed redirects MUST be capped by max-hops (default 3); on reaching the cap the last response is returned as-is even if itself a 3xx, without throwing, and max-hops 0 MUST disable redirect following entirely. +- **REDIR-18** (**MUST**): A malformed or unresolvable Location (invalid URI, illegal characters, unsupported scheme) MUST NOT throw — the step logs it and returns the current redirect response unfollowed. **REDIR-19** (**MUST**): a redirect response with a missing or empty Location MUST be returned unfollowed. +- **REDIR-22** (**MUST**): The step MUST manage response-body lifecycle deterministically: before issuing a follow-up the prior redirect response's body MUST be closed; if building the follow-up throws (non-replayable body, downgrade rejection) the current response MUST be closed before the error propagates; on any "return current" outcome (not-a-redirect, opted-out, malformed/missing Location, loop detected, max hops) the returned response is left OPEN for the caller. **REDIR-23** (**SHOULD**): redirect following SHOULD be an iterative loop, not unbounded recursion, so it is stack-safe. + +### 10.4 Predicates, options, and observability + +- **REDIR-20** (**MUST**): A configured redirect predicate MUST fully override the built-in decision and receive a READ-ONLY, defensively-copied condition snapshot (the current response, the count of redirects already followed, and an insertion-ordered set of visited URIs including the current request's), so it cannot mutate the live cycle-detection state. **REDIR-21** (**SHOULD**): on the non-redirect fast path (a status that is not a recognized redirect code) the implementation SHOULD short-circuit before allocating a condition snapshot and MUST NOT consult the predicate; but a recognized 3xx always allocates the snapshot and consults the predicate, even with no usable Location. +- **REDIR-26** (**MUST**): The configured allowed-method set MUST be stored as an immutable defensive copy so post-construction mutation of the caller's collection cannot change policy. **REDIR-27** (**MAY**): the header the redirect target is read from MAY be configurable (default `Location`). **REDIR-28** (**SHOULD**): each followed hop, loop detection, and scheme-downgrade event SHOULD be emitted as structured records with URLs passed through a redactor, redaction failures degrading to a placeholder rather than crashing logging; the malformed-Location event is the exception — it logs the raw Location string as received (it failed to parse, so cannot be redacted), which a port receiving credential-bearing malformed values should account for. + +## 11. Authentication + +Authentication has two largely independent halves: a scheme-agnostic descriptor/resolver model that decides which auth alternative an operation requires, and a stamping/challenge half that puts credentials on the wire and reacts to server challenges. A port must preserve the security invariants (never leak credentials over plaintext or cross-origin, unpredictable Digest cnonce, secret redaction), the deterministic resolution semantics, and the exact challenge/retry lifecycle. + +### 11.1 The descriptor/resolver model + +- **AUTH-1** (**MUST**): The recognized scheme set MUST be exactly `{OAUTH2, API_KEY, BASIC, DIGEST, NO_AUTH}`, where NO_AUTH is a distinct sentinel meaning "may run anonymously / skip credential stamping" rather than a wire scheme. **AUTH-2** (**MUST**): a requirement MUST bind exactly one scheme to its own OAuth scopes and params (meaningful only for OAUTH2, never inspected by resolution but preserved), be immutable (input collections mutated after construction do not affect the stored value), and have value-based equality over scheme + scopes + params. **AUTH-3** (**MUST**): a descriptor MUST be a non-empty ordered list of requirements in preference order, reject an empty list at construction, be immutable, and report "allows anonymous" true iff any requirement's scheme is NO_AUTH. +- **AUTH-4** (**MUST**): Tier resolution MUST select the single most-specific descriptor present, in the strict order per-call > operation > client, and resolve only against that descriptor; a higher tier that is present but unsatisfiable MUST NOT fall through to a lower tier — it fails, because the caller asked for that override explicitly. **AUTH-5** (**MUST**): within the selected descriptor, resolution MUST return the first requirement in declared order whose scheme is satisfiable, where satisfiable means NO_AUTH (always) or membership in the supplied set of available schemes, without inspecting any concrete credential. **AUTH-6** (**MUST**): resolution MUST fail with an argument error when all tiers are absent, and with a distinct auth-resolution error (carrying the required schemes in preference order and the available schemes) when the selected descriptor lists no satisfiable scheme. **AUTH-7** (**MUST**): the resolver MUST be stateless, concurrency-safe, and a deterministic pure function of its inputs. + +### 11.2 Credentials + +- **AUTH-8** (**MUST**): Every credential type MUST redact its secret in any string/diagnostic representation without mutating or corrupting the real fields, MAY leave non-secret fields visible, and MUST preserve its variant-specific equality: the bearer token has value-based equality over its real token+expiry (the redacted string form does not affect it), while the API-key and name-key credentials use reference identity (two instances with identical fields are NOT equal). *Reference note: 401 eviction/refresh matching is done on the stamped header string, not credential equality, so value equality is not required for the key credentials.* **AUTH-9** (**MUST**): credential construction MUST validate secret and identity fields as non-blank and reject blanks (bearer token, API key, name-key name and key). **AUTH-10** (**MUST**): bearer-token expiry MUST be optional (null = never locally expires) and evaluated additively with a grace margin — expired at reference time `now` with margin `M` iff expiry is non-null and `now + M` is strictly after expiry. **AUTH-11** (**MUST**): a token provider's fetch errors MUST propagate and MUST NOT be cached (so a subsequent request retries), and async callers MUST observe a provider error through the asynchronous channel (a failed future), never a synchronous throw. + +### 11.3 Challenge parsing and handlers + +- **AUTH-12** (**MUST**): The challenge parser MUST parse RFC 7235 `WWW-Authenticate`/`Proxy-Authenticate` values into an ordered list of challenges, honoring multiple comma-separated challenges, quoted-string values containing commas and `=`, backslash escapes, scheme/param names normalized to lower case, values stored verbatim after unquoting, a bare scheme emitted with an empty parameter map, and a token68 value recorded under a synthetic key. **AUTH-13** (**MUST**): the parser MUST be lenient and never throw — blank input yields an empty list, a malformed challenge recovers to the next top-level comma, an unterminated quoted string terminates at end-of-input, and parameters parsed before a malformed tail are preserved. +- **AUTH-14** (**MUST**): Basic stamping MUST produce `Basic ` + base64(UTF-8 of `username:password`), computed once, accept a Basic challenge case-insensitively, emit Authorization (or Proxy-Authorization for a proxy challenge), and validate credentials as non-empty (permitting whitespace-only per RFC 7617, laxer than the non-blank rule elsewhere). +- **AUTH-15**–**AUTH-22** (**MUST**): Digest stamping MUST support exactly `{MD5, MD5-sess, SHA-256, SHA-256-sess}` with qop `auth` (or absent), declining `auth-int`-only challenges, unsupported algorithms, and mutual-auth verification (**AUTH-15**); consider a challenge satisfiable iff scheme is Digest (case-insensitive), it carries realm and nonce, qop contains `auth` or is absent, and the algorithm is supported or absent (defaulting to MD5), preferring the algorithm earliest in the configured preference list regardless of wire order (**AUTH-16**); compute HA1/HA2/response per RFC 7616/2069 with lower-case hex of the selected algorithm (**AUTH-17**); track the nonce count per server nonce starting at `00000001` and incrementing only on reuse, rendered as exactly 8 lower-case hex digits using the low 32 bits on overflow (**AUTH-18**); draw the client nonce from a cryptographically strong source with at least 128 bits of entropy (**AUTH-20**); use UTF-8 hash-input encoding when the challenge advertises `charset=UTF-8` and ISO-8859-1 otherwise (**AUTH-21**); and quote/escape the appropriate fields, leave qop/nc/algorithm unquoted with the full algorithm spelling, and use the request-target digest-uri, emitting cnonce/nc/qop only when qop is negotiated (**AUTH-22**). **AUTH-19** (**SHOULD**): the per-nonce counter store SHOULD be bounded (default 1024) and drained under the cap, evicting a live nonce being harmless because its nc restarts at 1 (spec-legal for a fresh nonce). +- **AUTH-23** (**MUST**): Composing handlers MUST delegate to the first handler in declaration order whose can-handle check passes and MUST defensively copy the handler list; callers order stronger schemes first. **AUTH-24** (**MUST**): handlers MUST be safe for concurrent invocation, with per-handler mutable counters (e.g. Digest nc) using thread-safe primitives so concurrent reuse of one nonce still yields correct, non-duplicated counts. **AUTH-25** (**MUST**): a handler MUST emit Authorization for WWW-Authenticate challenges and Proxy-Authorization for Proxy-Authenticate challenges, selected by an explicit proxy flag, and return no header when it cannot satisfy any offered challenge. **AUTH-26** (**MUST**): static key-credential stamping MUST write the key into the configured header (default Authorization) and, when a prefix is configured, prepend it followed by a single space, with the stamping step stateless after construction. + +### 11.4 The AUTH pillar step + +- **AUTH-27** (**MUST**): There MUST be exactly one auth step occupying the single AUTH pillar stage, running nested inside both the redirect loop and the retry loop (auth executes per redirect hop and per retry attempt; redirect wraps retry wraps auth). **AUTH-28** (**MUST**): on any path where a credential will be attached, the auth step MUST reject a non-HTTPS request URL (case-insensitive) BEFORE any token fetch or header stamping, failing with an error naming the concrete step and the offending scheme; credentials MUST NOT be stamped over plaintext. +- **AUTH-29** (**MUST**): On a cross-origin redirect re-issue (differing scheme, host, or effective port under the RFC 6454 tuple, marked by the redirect step), the auth step MUST NOT stamp the caller's credential, MUST strip the internal cross-origin marker so it never reaches the wire, and MUST skip the HTTPS guard (no credential is attached) so a deliberately-allowed downgrade hop is forwarded credential-free rather than hard-failing; a same-origin re-issue MUST be re-stamped normally and remains subject to the HTTPS guard. The suppression mechanism MUST only be able to suppress stamping, never force a credential to be sent. +- **AUTH-30** (**MUST**): On a 401 carrying a `WWW-Authenticate` header, the auth step MUST consult its challenge hook; if the hook yields a non-null replacement request, the step MUST close the original 401 and drive the replacement through a fresh copy of the downstream chain exactly once, with no further challenge handling on the replacement (a single re-challenge attempt). The default hook yields no replacement. **AUTH-33** (**MUST**): a 401 without a `WWW-Authenticate` header MUST be returned unchanged without consulting the hook. **AUTH-32** (**MUST**): if the hook throws (or its async future completes exceptionally, or the async hook throws synchronously), the step MUST close the open 401 response body before propagating. +- **AUTH-31** (**MUST**): The 401 re-challenge replay MUST be gated on request-body replayability: if the replacement carries a non-replayable body, the step MUST skip the replay, surface the original 401 unchanged, and MUST NOT close that original response (the caller owns it). *Reference note: the reference enforces this gate on the synchronous auth step only; the async auth step does not currently apply a replayability gate and closes the original 401 before re-driving unconditionally. A faithful port SHOULD apply the same gate on both paths.* +- **AUTH-34**–**AUTH-37** (**MUST**): The bearer auth step MUST stamp `Authorization: Bearer ` using a token cached until a configurable refresh margin before expiry (default 30 s), ensuring concurrent requests racing on a missing/expiring token result in at most one provider fetch (single-flight) with a non-blocking hot-path read of a valid cached token (**AUTH-34**); MUST reject a null token and a token already expired at fetch time (evaluated with NO margin), and MUST NOT cache a thrown provider error (**AUTH-35**); on a 401 advertising a Bearer challenge MUST evict only the exact cached token that produced the 401 (matched by the stamped header value) and re-stamp a single retry with a freshly fetched token, preserving a token another request already refreshed, surfacing the 401 unchanged when the rejected request carried no Authorization header or the response advertises no Bearer challenge, and firing the eviction-driven retry regardless of HTTP method (**AUTH-36**); and the async bearer step MUST implement a three-zone expiry policy without blocking the dispatching thread — fresh (stamp, no refresh), expiring-but-valid (stamp the still-valid token immediately AND kick off an off-thread background refresh), expired/missing (await a fresh single-flight fetch) — coalescing concurrent expiring/missing requests onto one fetch, not caching a failed fetch, and treating a failed background refresh as non-fatal since a valid token was already stamped (**AUTH-37**). **AUTH-38** (**SHOULD**): in the async path the HTTPS-guard failure and any hook error SHOULD be delivered through the asynchronous channel (a failed future) rather than synchronously thrown. + +## 12. Pagination + +The pagination engine turns a paginated HTTP endpoint into a lazy stream of items or of whole pages, for both blocking and non-blocking callers. It is transport-agnostic and serde-agnostic: a single stateless *strategy* parses each response into the page's items plus the fully-formed request for the next page (or an end-of-stream signal), and the engine drives iteration, owns each page's live HTTP response, and bounds a misbehaving server with a page cap. A port MUST preserve the two-view model, the page-lazy fetch discipline, deterministic response-lifecycle management, and the strategy contract described here. + +### 12.1 Views, laziness, and re-iteration + +The engine exposes two consumption views over one walk: an *item-level* view that flattens each page's items into a single ordered sequence, and a *page-level* view that yields whole pages (each exposing raw per-page status, headers, originating request, and the live response). + +- **PAGE-1 (MUST).** Both views MUST be available over the same walk, and items MUST be delivered in server-defined order across page boundaries. *Rationale: item consumers want a flat stream; metadata consumers want per-page access.* *Conformance: paginate a 3-page fixture; assert the item view yields all items concatenated in server order and the page view yields exactly three page objects with the expected status/headers.* +- **PAGE-6 (MUST).** Iteration MUST be page-lazy: exactly one HTTP exchange occurs per page actually yielded. For the blocking engine, constructing the paginator, obtaining the iterable/stream, and obtaining the item iterator MUST trigger zero exchanges; the first exchange happens only when the consumer first probes for data. The non-blocking engine has no separate lazy "obtain" step — invoking a walk method is itself the consumption trigger and begins fetching immediately — but the one-exchange-per-page-consumed guarantee still holds. *Rationale: paging must have no side effects until consumed and a predictable per-page cost (rate limits, billing, effect ordering).* *Conformance: call the item iterator with a counting transport; assert zero exchanges until the first probe, then one exchange per page consumed.* +- **PAGE-7 (MUST).** Fetching MUST advance only forward and only on demand: after a page whose strategy returned a null/absent next-request, the engine MUST perform no further exchange, and repeated end-of-stream probes MUST be idempotent (no re-fetch). An empty page carrying a non-null next-request still counts as one consumed exchange. *Conformance: after the terminal page, call next/has-next repeatedly and assert no additional exchange.* +- **PAGE-8 (MUST).** Each independent iteration MUST restart from the initial request with its own fresh state; the engine itself MUST hold only immutable configuration and be safe to share. A single returned iterator/stream/walk MAY be single-consumer, but two separate iterations from the same engine MUST each drive a full fetch sequence and yield identical results. *Conformance: iterate twice on one paginator; assert equal results and two full transport fetch sequences.* +- **PAGE-36 (MUST).** Per-call request overrides (timeout, retry budget, tags) supplied to the strategy-based engine MUST be applied to *every* page exchange, not just the first. The default is no overrides. *Rationale: otherwise a caller's timeout/retry policy silently fails to govern pages 2..N.* *Conformance: configure a per-call override and assert the transport receives it on every page request.* + +### 12.2 The `Page` value and response ownership + +A `Page` wraps the live transport response and splits its state by lifetime. + +- **PAGE-2 (MUST).** A page's materialized item list and its derived status/headers/originating-request MUST remain readable after the page is closed; only the raw response body/connection becomes invalid at close. Items MUST never be null (they MAY be empty). *Conformance: close a page, then read items/status/headers/request and assert the pre-close values still hold.* +- **PAGE-3 (MUST).** A page MUST be a closeable resource owning exactly one underlying response; whoever pulls a page owns closing it, and closing the page MUST release that response's body/connection. A component that hands a caller a live page (e.g. a first-page fetcher) MUST NOT itself close the response — ownership transfers to the page. *Conformance: build a page over an instrumented response, close it, and assert the response was closed exactly once.* + +### 12.3 Strategy contract and `PageInfo` + +A strategy is a stateless parser: given a response and the original request template, it returns a `PageInfo` carrying the page's items plus the next-page request. + +- **PAGE-4 (MUST).** A strategy's parse output MUST carry items plus a next-request value where a null/absent next-request is the single, exclusive end-of-stream signal the engine recognizes. Parse MUST always return a well-formed result (never null) and MUST signal termination only by that null next-request — never by throwing and never via a side channel. An empty items list paired with a non-null next-request is a valid non-terminal page. *Conformance: a non-null next-request → fetch again; null → stop; empty items + non-null next → fetch one more page.* +- **PAGE-5 (MUST).** A strategy MUST read everything it needs from the response synchronously inside parse (the body is single-use), MUST NOT retain the response or its body beyond the call, and MUST NOT close or mutate the response — lifecycle ownership belongs to the engine. Strategies MUST be immutable and safe to share concurrently. *Conformance: run one strategy instance across two concurrent walks and assert correctness.* + +### 12.4 The page cap + +- **PAGE-9 (MUST).** The engine MUST accept a page cap (maximum exchanges) bounding a server that never advances its cursor. The cap counts exchanges/pages, not items; once reached, the engine MUST stop fetching even if the strategy still reports a next-request. The cap MUST be validated as strictly positive at construction (fail fast), not lazily. *Conformance: with a server echoing the same cursor forever and cap=N, assert exactly N exchanges then termination; assert cap ≤ 0 throws at construction.* +- **PAGE-10 (SHOULD).** The default cap SHOULD be effectively unbounded (matching plain lazy-sequence semantics), and documentation SHOULD direct production callers to set a finite cap. *Conformance: assert a no-cap overload permits an arbitrarily long stream.* + +### 12.5 Close-on-abandon discipline + +The two views release responses differently. + +- **PAGE-11 (MUST).** The item-level view MUST eager-close each page *before* yielding any of that page's items (after copying the materialized items), so abandoning item iteration mid-page never strands the response. *Conformance: take one item from a multi-item first page and stop; assert the first page's response was closed and no second page was fetched.* +- **PAGE-12 (MUST).** The page-level view MUST be auto-closing with a close-on-abandon guarantee: close the previous page as the consumer advances, close the last page at exhaustion, and — because probing for the next page eagerly runs that page's exchange — buffer the fetched-but-undelivered page in storage it owns so an emptiness probe or early break followed by an explicit close still releases it. Explicit close MUST release both the currently-held page and any buffered page. Consumers MUST be told to wrap the view in a scoped/auto-close construct. *Rationale: up to two live pages can exist at once.* *Conformance: probe has-next without advancing, then close; assert the prefetched page's response is closed. Break out of a page loop early inside a scoped-close block; assert the held page is closed.* +- **PAGE-14 (MUST).** The page-level view MUST be single-use: its iterator/stream may be obtained at most once, and re-iteration MUST fail rather than silently restart. *Conformance: obtain the iterator twice and assert the second call throws.* +- **PAGE-15 (MUST).** A close error while releasing held page(s) MUST be surfaced, not swallowed. When exposed through a stream whose terminal cannot declare the underlying I/O error type, it MUST be re-thrown wrapped so the caller can still catch it at the close site. When both held pages fail to close, the first failure MUST propagate with the second attached as suppressed. *Conformance: make a held page's close throw and consume via a short-circuiting terminal inside a scoped close; assert the wrapped close error surfaces.* + +### 12.6 Parse-failure handling + +- **PAGE-13 (MUST).** If a strategy's parse throws, the engine MUST close that response inline on the exceptional path (the page is never constructed, so nothing else would close it) and then propagate the failure. A close failure MUST NOT mask the parse failure — it MUST be attached as a suppressed/secondary error, with the parse error primary. *Conformance: make parse throw; assert the response closed exactly once and the parse error propagates; make both parse and close throw; assert the parse error propagates with the close error suppressed.* + +### 12.7 Built-in strategies + +- **PAGE-16 (MUST) — cursor.** The cursor strategy MUST read items and the next cursor from a single read of the response body, MUST treat a null OR empty next cursor as end-of-stream, and MUST derive the next request by setting a configurable cursor query parameter (default `cursor`) on the template. *Conformance: cursor "c" → next request has `cursor=c`; null/empty cursor → stream ends; assert the body is read once.* +- **PAGE-17 (MUST) — page-number.** The page-number strategy MUST treat an empty items list as end-of-stream (defensive against servers that return an empty page past the end). Otherwise it MUST infer the current page from the originating (executed) request's page query parameter, defaulting to a configurable start page (default 1) when that parameter is absent, empty, or non-numeric, and set the next request's page to current+1. The page parameter name (default `page`) and start page (default 1, allowing 0-based servers) MUST be configurable. *Conformance: first page (no param) with non-empty items → next request `page=start+1`; empty items → stream ends; garbage page value → next computed from start.* +- **PAGE-18 (MUST) — Link header.** The Link-header strategy MUST select the next URL from the Link header(s) using RFC 5988/8288 semantics — the first link-value whose `rel` contains the token `next` (case-insensitively; `rel` may be quoted/unquoted and list multiple space/tab-separated types). It MUST parse link-values so commas inside angle-bracketed URLs or quoted parameter values do not split link-values, and support quoted-pair escapes. Absence of a Link header or a rel=next segment MUST mean end-of-stream. The header name MUST be configurable (default `Link`). *Conformance: feed Link values with quoted commas, unquoted rel, multi-token rel, and mixed rel=prev/last; assert the correct next URL and that quoted commas do not split.* +- **PAGE-19 (MUST) — reference resolution.** A rel=next target MUST resolve as an RFC 3986 reference against the originating page's response URL: absolute targets used as-is, relative targets resolved against the base. A query-only reference (starting with `?`) MUST preserve the base URL's full path and replace only the query (it MUST NOT drop the last path segment, the RFC 2396 behavior). A target that cannot resolve into a valid URL MUST be treated as end-of-stream, not an error. *Conformance: base `/repo/issues?page=1` + `; rel=next` → `/repo/issues?page=2`; `; rel=next` → stream ends, no exception.* +- **PAGE-20 (SHOULD).** When a server splits pagination links across multiple separate Link header instances, the strategy SHOULD normalize them (e.g. by concatenation), and an empty header set SHOULD map to no next link. *Conformance: return two separate Link headers (one next, one last); assert next is followed.* + +### 12.8 Request rewriting (verbatim query splice) + +The next-page request is built by splicing the query string, not by re-rendering the whole URL. + +- **PAGE-21 (MUST).** When rewriting the next-page query string, the rebuilder MUST splice the raw query verbatim: every untargeted parameter is copied byte-for-byte (value-less flags stay value-less, reserved characters are not rewritten, order preserved) and only the targeted parameter's name/value is decoded/encoded. It MUST NOT re-render or canonicalize the whole query. *Conformance: rewrite `page` on `?flag&filter=a:b&page=1` → `?flag&filter=a:b&page=2` with `flag` and `filter` byte-identical.* +- **PAGE-22 (MUST).** Encoding a newly-set parameter MUST use RFC 3986 component encoding (space → `%20`, literal `+` preserved as data, not treated as a space), and reading a parameter MUST decode with the same RFC 3986 semantics (a literal `+` reads back as `+`, `%20` as a space, a value-less flag as empty string, first match wins). *Conformance: set `q='a b'` → `q=a%20b`; set `token='a+b/c='` → `token=a%2Bb%2Fc%3D`; get of `q=a+b` → `a+b`.* +- **PAGE-23 (MUST).** Setting a query parameter MUST replace the first existing occurrence in place (dropping further duplicates — single-value convention for paging params), append if absent, and remove entirely when the new value is null/absent; order MUST otherwise be preserved. Following an absolute/whole next URL MUST instead swap only the request's URL, preserving the template's method, headers, and body. *Conformance: `?page=1&sort=asc` set page=2 → `?page=2&sort=asc`; set page=null → `?sort=asc`.* +- **PAGE-24 (MUST).** URL rewriting MUST preserve all non-query components exactly: scheme/protocol, userinfo, host, port, path, and fragment. Only the query may change. *Conformance: rewrite a query param on a URL with userinfo, an explicit port, and a fragment; assert all survive unchanged.* + +### 12.9 Asynchronous paging + +The async engine drives the walk inside a future/completion graph without blocking a thread per page. + +- **PAGE-25 (MUST).** The async engine MUST drive fetch, parse, delivery, and re-arm inside the async completion graph without any thread blocking on a page. Cancelling/completing the walk's result future MUST halt the walk (no further pages) and best-effort abort the in-flight exchange by cancelling its transport future. *Conformance: run a walk; assert no worker thread blocks per page; cancel the result future mid-walk and assert the in-flight transport future is cancelled and no further exchange dispatched.* +- **PAGE-26 (MUST).** Async cancellation MUST take effect at page granularity: if the result settles mid-drain, items already being delivered from that page still reach the consumer; the driver stops at the next page boundary rather than interrupting an in-progress drain. A page fetched-but-not-yet-drained when the result settles MUST be dropped undrained AND closed; any close error on that already-settled path MUST be swallowed. *Conformance: complete the result future while a fetched page is staged; assert that page's response is closed, the walk ends cleanly, no leak, no masking error.* +- **PAGE-27 (MUST).** The async engine MUST close each page's response exactly once on whichever path consumes it — after drain, when a fetched-but-undrained page is dropped (external settlement or rejected re-dispatch), or inline on parse failure. No double-close, no leak. *Conformance: instrument responses across normal completion, parse failure, cancellation, and executor-rejection paths; assert each closes exactly once.* +- **PAGE-28 (MUST).** A consumer that throws, a transport/connection failure, a parse failure, or a null success completion MUST terminate the walk and complete the result future exceptionally, surfacing the *original* underlying cause (unwrapping any future-composition wrapper). A transport that eagerly throws instead of returning a failed future MUST be handled as a failed walk. *Conformance: fail the transport, throw from parse, throw from the consumer, complete with null, and eager-throw; assert the result future completes exceptionally with the expected cause each time.* +- **PAGE-29 (MUST).** For a single async walk the consumer MUST NOT be invoked concurrently, and items MUST be delivered one at a time in server order; the consumer MUST NOT assume a particular thread. By default the consumer runs inline on the page-completion thread. The engine MUST also offer a mode running the driver — and therefore every consumer invocation — on a caller-supplied executor, so a blocking consumer does not tie up transport callback threads. *Conformance: assert serial, ordered delivery; with the executor overload assert consumer invocations run on that executor.* +- **PAGE-30 (MUST).** If the async engine is given an executor that rejects a (re-)dispatch, the walk MUST terminate with the result future completed exceptionally carrying the rejection, and any staged page MUST be closed. It MUST NOT hang the future or leak the rejection. *Conformance: use an executor that rejects the second dispatch after a page is staged; assert the future fails with the rejection and the staged page is closed.* +- **PAGE-31 (SHOULD).** The async driver SHOULD process synchronously-completed page futures iteratively (a trampoline), not via recursive future composition, so an arbitrarily long run of already-complete pages does not overflow the stack. A port on a runtime without deep-recursion risk MAY satisfy the intent with its native loop model but MUST NOT recurse per page. *Conformance: drive thousands of synchronously-completed pages through both inline and executor paths; assert completion with no stack overflow.* +- **PAGE-32 (MUST).** In the async drain path, releasing a page's response MUST happen whether the consumer succeeds or throws, and a throwing close MUST NOT escape the driver: on the success path a throwing close MUST be reported through the result future (so the walk terminates instead of hanging); if the consumer already failed, that cause stays primary and the close error is swallowed. *Conformance: make close throw on the success path; assert the future completes exceptionally, not hangs.* +- **PAGE-33 (MUST).** A port MUST document the inherent async cancellation race: if an external cancel settles the transport future *before* the transport delivers its response, that response never reaches the paginator's close path — releasing it is the transport's responsibility (cancelling the walk's future cannot reach into an already-built response). Conversely, a page request already dispatched MAY still complete after the abort; if it completes successfully the paginator MUST close and discard that response. + +### 12.10 Fetcher-based front-end + +An alternative front-end lets the caller supply a first-page fetcher and a next-page fetcher instead of a strategy. + +- **PAGE-34 (MUST).** The fetcher-based front-end MUST call the first-page fetcher exactly once, then drive subsequent pages by keying the next-page fetcher off the previous page's next link, falling back to its continuation token when no next link is present (next link wins). An empty/blank next link with no fallback token, or a null page from either fetcher, MUST end the stream (a null first page yields an empty stream). Each fetcher MUST build a page that owns its response and MUST NOT close it; a fetcher that throws before building the page remains responsible for that response. *Conformance: assert first-page fetcher called once, next-page fetcher called with prev.nextLink (or token), and a blank link or null return terminates.* +- **PAGE-35 (SHOULD).** If a mutable paging-options object is offered to fetchers, the *same* instance SHOULD be threaded through every fetcher call so a custom retriever can stash cursor/state between pages, and this cross-call mutation visibility SHOULD be documented; such an options object is single-consumer and need not be thread-safe. *Conformance: assert both fetchers receive the identical options instance; mutate its token in one call and assert the next observes it.* + +--- + +## 13. Server-Sent Events and Streaming + +The SSE subsystem parses a byte stream into discrete events following the WHATWG SSE line/field grammar, exposes each event as an immutable value, and layers a resource-owning, single-pass, lazily-decoding streaming facade (raw and typed) whose lifecycle is bound to the underlying HTTP response so a partial or failed consume never strands the connection. It deliberately owns no reconnection policy, no last-event-id continuity, and no per-API sentinel conventions — those live in caller-supplied code. + +Several requirements here are *deliberate deviations* from strict WHATWG (comment exposure, permissive dispatch, EOF partial-dispatch). A port aiming for parity with this subsystem MUST replicate them, or offer a strict-WHATWG mode. + +### 13.1 Line and field parsing + +- **SSE-1 (MUST).** The stream MUST be parsed line by line, with a blank line acting as the event-dispatch boundary: at a blank line the accumulated fields collapse into exactly one event, and a fresh set of per-event accumulators governs the next block. *Conformance: `data: 1\n\ndata: 2\n\n` yields two events with data `['1']` and `['2']`; `id: 1\ndata: a\n\ndata: b\n\n` yields a second event whose id is absent.* +- **SSE-2 (MUST).** Line termination MUST recognize LF (`\n`), CR (`\r`), and CRLF (`\r\n`), treating CRLF as a single terminator with terminators stripped; a lone CR terminates a line by itself. *Conformance: parse the same event with each terminator and with mixed terminators; assert identical data lists.* +- **SSE-3 (MUST).** A non-comment line MUST be split at its first colon into field name and value. No colon → the whole line is the field name with empty value; a trailing colon → empty value. *Conformance: `data\n\n` and `data:\n\n` each yield data `['']`; an unrecognized field with no colon dispatches no event.* +- **SSE-4 (MUST).** A field present with an empty value (colon-less `data`, trailing-colon `data:`, or empty `event:`/`id:`) MUST be recorded with the empty string as value AND count as a "field seen" — distinct from the field being absent (surfaced as null/absent). A port MUST NOT collapse present-but-empty into absent. *Rationale: an empty `data` contributes an empty payload line; an empty `event` is a present empty event name.* *Conformance: `event:\ndata:x\n\n` → event is `''` (present), not absent.* +- **SSE-5 (MUST).** Extracting a field value (and comment text) MUST strip exactly one leading `U+0020` SPACE immediately after the colon if present; further leading spaces are preserved. *Conformance: `data: hello` → `hello`; `data: hello` → ` hello`.* +- **SSE-6 (MUST).** A line whose first character is `:` MUST be a comment: its text (after the single-space strip) is captured latest-wins within a block, and a comment counts as a "field seen" so a comment-only block dispatches. *Conformance: `:keep-alive\n\n` → an event with comment `keep-alive` and empty data.* +- **SSE-7 (MUST).** Only field names `id`, `event`, `data`, and `retry` MUST be interpreted; any other name MUST be silently discarded (setting no state, causing no dispatch). *Conformance: `garbage: zzz\nevent: kept\ndata: p\n\n` → event `kept`, data `['p']`, id/comment absent.* +- **SSE-8 (MUST).** Consecutive `data` fields within a block MUST accumulate, in wire order, into an ordered list of raw per-line values; the parser MUST NOT join them (joining is deferred to the typed layer). *Conformance: `data: line1\ndata: line2\n\n` → `['line1','line2']`.* +- **SSE-9 (MUST).** An `id` value containing a `U+0000` NUL MUST be ignored entirely: it does not set the id, does not count as a field seen, and does not overwrite a valid id already seen in the same block. A valid id is stored verbatim, latest-wins. *Conformance: `id: a\0b\ndata:x\n\n` → id absent; `id: good\nid: a\0b\ndata:x\n\n` → id `good`.* +- **SSE-10 (MUST).** The `event` field MUST be stored raw with latest-wins semantics and surfaced as absent/null when no `event` field was sent — it MUST NOT be defaulted to `message`. *Conformance: `data:x\n\n` → event absent.* +- **SSE-11 (MUST).** The `retry` value MUST be accepted only if it consists solely of ASCII digits `0`-`9`; a sign, embedded non-digit, empty value, or value exceeding the maximum representable millisecond magnitude MUST cause the field to be ignored. An accepted value is surfaced as a non-negative millisecond duration, latest-wins. The representable maximum is runtime-specific (signed 64-bit ms in the reference); a port MUST pick a documented cap and reject beyond it rather than wrap. *Conformance: `retry: 5000` → 5000ms; `retry: bad`/`retry: -100`/`retry:` leave retry unset.* +- **SSE-12 (MUST).** A single leading UTF-8 BOM (`EF BB BF` / `U+FEFF`) at the very start MUST be consumed once, using non-consuming lookahead so a non-BOM prefix is left intact; any BOM later in the stream MUST be preserved as ordinary data. *Conformance: prefix a stream with the BOM and assert the first event's fields exclude it; embed the BOM inside a later data value and assert `U+FEFF` survives.* + +### 13.2 Dispatch and end-of-stream + +- **SSE-13 (MUST).** Dispatch MUST be permissive: an event is emitted whenever *any* of the five tracked fields (id, event, data, comment, retry) was set in the block — so id-only, retry-only, and comment-only events are visible — while a block in which no field was set (pure blank lines) MUST be skipped. *Conformance: `id: 42\n\n` emits an event; `\n\n\n` emits nothing.* +- **SSE-14 (MUST).** At end-of-stream, if fields have accumulated but no terminating blank line was seen, the parser MUST dispatch the pending event; if no field accumulated it MUST signal end. A final line without a terminator at EOF is returned as content. *Conformance: `data: hello` (no trailing newline) yields one event then end; `''` yields immediate end.* +- **SSE-15 (MUST).** `next()` MUST return an end-of-stream sentinel exactly when the source is exhausted with no pending dispatchable fields, and MUST continue to report end on subsequent calls. *Conformance: after the last event, assert end, then assert a second call still returns end.* + +### 13.3 Reader statefulness and ownership + +- **SSE-16 (MUST).** The reader MUST be single-pass and stateful: only the "BOM already consumed" flag persists across calls; the last-event-id is NOT carried forward — each event surfaces only the id in its own block. A port MUST NOT maintain a WHATWG-style persistent last-event-id buffer inside the parser. *Conformance: `id: 1\ndata: a\n\ndata: b\n\n` → the second event's id is absent.* +- **SSE-17 (MUST).** The reader MUST NOT own or close the underlying byte source; source lifecycle is the caller's responsibility (resource ownership is introduced only by the stream facade, §13.5). *Conformance: drive a reader to completion over an instrumented source and assert its close was never invoked by the reader.* +- **SSE-18 (MUST).** A single reader instance MUST be driven from one thread at a time; the parser offers no thread-safety for concurrent `next()` calls. A port MAY leave the parser non-thread-safe. +- **SSE-19 (MAY).** The parser MAY accept arbitrarily long lines/values with no built-in size cap; the reference imposes none. This is a potential unbounded-memory surface for untrusted servers; a port MAY add a configurable cap and reject/truncate oversized lines, documenting the divergence. + +### 13.4 The event value + +- **SSE-20 (MUST).** The parsed event MUST be immutable and hold a defensively-copied, read-only data list so neither the caller's originally-supplied list nor later mutations can reach inside a constructed event; any copy-with-changes operation MUST likewise copy the data list. *Conformance: construct an event from a mutable list, mutate the original, assert the event's data is unchanged.* +- **SSE-21 (SHOULD).** The event SHOULD provide structural value semantics — equality/hash over all five fields, and a stable string form. *Conformance: two events with identical fields compare equal and share a hash.* +- **SSE-22 (SHOULD).** The event SHOULD expose an is-empty predicate true only when all five fields are unset/empty; because a comment counts as content, a comment-only event SHOULD report non-empty. *Conformance: an event with only a comment → is-empty false.* + +### 13.5 The streaming facade (resource lifecycle) + +The facade wraps a reader plus a closeable resource (the response/body) and guarantees exactly-once release across every termination path. + +- **SSE-23 (MUST).** The facade MUST own exactly one closeable resource and MUST close it exactly once across the stream's whole life, regardless of termination (clean end, explicit close, use-block exit, partial consume, mid-stream failure). *Conformance: wrap a close-counting resource; exercise each termination path and assert exactly one close.* +- **SSE-24 (MUST).** On reader end-of-stream during iteration, the facade MUST both terminate the iterator cleanly AND release the resource, so a fully-consumed stream needs no explicit close. *Conformance: iterate to completion without close; assert the resource closed once.* +- **SSE-25 (MUST).** A partial consume MUST NOT strand the resource: closing after reading only some events MUST release it. *Conformance: pull one event inside a use-block over a multi-event stream, exit, and assert one close.* +- **SSE-26 (MUST).** The facade MUST be single-pass: obtaining an iterator succeeds at most once; a second attempt MUST fail loudly. *Conformance: obtain the iterator twice and assert the second throws.* +- **SSE-27 (MUST).** After close, requesting an iterator MUST fail loudly and an in-flight iterator MUST observe the closed state and end cleanly on its next pull — neither may read from the torn-down resource. *Conformance: close then request iterator → throws; close mid-iteration between pulls → next has-next returns false, resource closed once.* +- **SSE-28 (MUST).** `close()` MUST be idempotent — only the first call propagates to the owned resource — and this MUST hold even after an automatic release on a terminal/failure path. *Conformance: call close three times, assert one release; after an auto-release, call close and assert the count stays 1.* +- **SSE-29 (MUST).** A mid-stream reader failure MUST release the resource *before* the error propagates; if releasing itself fails while an error is in flight, that release failure MUST be attached to the primary error as a suppressed/secondary throwable. *Conformance: throw after N events; assert the error surfaces on the next pull, the resource closed once, and the close failure appears suppressed where the language supports it.* +- **SSE-30 (MUST).** A release failure on an *automatic* clean-terminal path (natural end or done-sentinel, no error in flight) MUST NOT be turned into a thrown result that discards delivered events; it MUST be reported out-of-band and swallowed. A release failure during an *explicit* `close()` MUST propagate. The out-of-band mechanism (a WARN log in the reference) is an implementation detail; the swallow-vs-propagate split is the portable contract. *Conformance: with a resource whose close throws, a full iteration returns all events; an explicit close throws.* +- **SSE-31 (MUST).** `close()` MUST be safe from a different thread than the one iterating (to cancel a long-lived stream); the closed state is guarded atomically. A close observed *between* pulls ends iteration cleanly; a close that tears the resource down while a read is blocked *in-flight* surfaces as a read failure (an I/O error). Either way the resource is released exactly once. *Conformance: park a reader thread inside a blocking read, close from another thread, and assert an I/O-style error plus one release; separately close between pulls and assert a clean end.* +- **SSE-32 (MUST).** The convenience that opens a stream over an HTTP response MUST bind the stream's lifecycle to the response body (closing the stream closes the response) and MUST fail loudly if the response has no body. *Conformance: open over a response with an instrumented body, consume, assert one close; open over a bodyless response and assert it throws.* + +### 13.6 The typed adapter + +- **SSE-33 (MUST).** The typed adapter MUST invoke the mapper with `(event-name, joined-data)` where event-name is the raw `event` field (absent/null if omitted) and joined-data is the data lines joined with a single `\n` (empty string when no data), and MUST yield the mapper's decoded value. *Conformance: a multi-line-data event → mapper receives the lines joined by `\n`; a no-data event → `''`.* +- **SSE-34 (MUST).** The adapter MUST honor the mapper's three outcomes: a value is yielded; a Skip silently drops the event and advances (never surfacing to the consumer); a Done ends iteration cleanly and closes the stream without yielding a model for the sentinel event. *Conformance: Skip for keep-alives, Done for a sentinel, Value otherwise — skipped events absent, sentinel closes the resource, post-sentinel events never decoded.* +- **SSE-35 (MUST).** Typed decoding MUST be lazy and per-element: the mapper runs only when the consumer pulls the next element, so a partial consume decodes only the events taken. *Conformance: instrument the deserializer; pull one element → one decode; pull a second → two.* +- **SSE-36 (MUST).** A mapper that throws MUST propagate the exception to the consumer's pull, but MUST first release the underlying resource; a resulting release failure MUST be attached to the mapper error as suppressed. *Conformance: a mapper that throws on a given event → the exception surfaces on that pull and the resource closed once.* + +### 13.7 Toolkit boundaries and backpressure + +- **SSE-37 (MUST).** Core parsing/streaming MUST remain format- and API-agnostic: no built-in done-sentinel, no error-envelope recognition, no serialization dependency — all such conventions live only in the caller-supplied mapper. In the reference, `sdk-core` carries zero serialization dependency, making this a hard architectural invariant. *Conformance: confirm no sentinel string or serde call exists in the reader or stream facade.* +- **SSE-38 (MUST).** Reconnection and last-event-id continuity MUST remain the caller's responsibility: the subsystem surfaces the retry hint and each event's raw id but MUST NOT auto-reconnect, MUST NOT persist a last-event-id across events (see SSE-16), and MUST NOT set a reconnect request header. A callback listener contract MAY be offered whose retry/close/error hooks default to no-ops and are not auto-driven by core. *Conformance: confirm no code path re-opens a connection or writes a Last-Event-ID header.* +- **SSE-39 (MUST).** Event delivery MUST be pull-based with no eager read-ahead: the parser advances the source only when the consumer requests the next event, so a blocking source read is the backpressure mechanism and no unbounded internal buffer accumulates. A reactive/async adapter MUST preserve this — polling the source at most once per unit of downstream demand. The typed layer may pull several raw events per yielded element to drain Skips, but only as many as needed to produce one element. *Conformance: instrument raw `next()` call count against consumer pulls and assert 1:1 with no prefetch; for the reactive adapter, `request(n)` and assert at most n source polls.* +- **SSE-40 (SHOULD).** Sequence/iterable convenience views over a raw source SHOULD be lazy, single-pass, and propagate read exceptions at the offending pull; they SHOULD reuse one reader instance so per-stream state (BOM consumption) is preserved, and MUST NOT be invoked twice on the same source. *Conformance: consume the view over a stream with a mid-stream read failure and assert the exception surfaces on the failing element.* +- **SSE-41 (MAY).** A reactive adapter MAY catch only recoverable exceptions and let the runtime's fatal/VM error family escape rather than routing it through the error channel, and MAY leave source lifecycle to the caller. A port SHOULD apply its own runtime's fatal/non-fatal split and document source ownership. + +--- + +## 14. Serialization (Serde) + +Serde is the SDK's format-agnostic serialization seam. It defines a small SPI — a `Serde` bundling an encoder, decoder, and declared wire media type — so every subsystem can round-trip typed values through a single injection point without naming a concrete codec. The core ships only abstractions (Serde/Serializer/Deserializer, the generic type carrier, the three-state `Tristate` sum type for PATCH, and a stable exception hierarchy); a concrete codec (Jackson) plugs in at the edge. + +### 14.1 The Serde bundle and media type + +- **SERDE-1 (MUST).** A Serde MUST be a single bundle exposing exactly one encoder and one decoder for one wire format, so consumers acquire both through one reference. *Conformance: assert the serializer and deserializer round-trip a value with each other.* +- **SERDE-2 (MUST).** A Serde MUST declare the wire media type it produces, and that media type MUST be used as the default Content-Type when a request body is created from a value plus a Serde. The media type MUST NOT be defaulted to a format-agnostic constant at the SPI level. *Rationale: a format-agnostic default would let a non-JSON serde silently stamp the wrong Content-Type.* *Conformance: build a body via `create(value, serde)` with no explicit media type; assert the Content-Type equals the serde's declared media type.* + +### 14.2 The Serializer (allocation profiles) + +- **SERDE-3 (MUST).** When encoding into, or decoding from, a caller-supplied stream, the serializer/deserializer MUST read/write the payload fully (to EOF on the read side) but MUST NOT close or take ownership of the caller's stream. The encode-into-buffer profile likewise touches only the target region and never assumes ownership. *Conformance: wrap a caller stream in a close-counting tracker; serialize/deserialize through it; assert bytes transferred and close count 0, even when the codec's own auto-close feature is enabled.* +- **SERDE-4 (MUST).** The encode-into-buffer profile MUST return the number of bytes written, MUST honor a start offset, and MUST throw a range/overflow error (distinct from the serde exception type and not chaining one) when the offset is out of range or the payload does not fit. Bytes before the offset MUST be left untouched. *Conformance: encode into an oversized buffer at offset N; assert the return equals the standalone length, region `[N, N+len)` matches, and `[0,N)` unchanged; encode into a one-byte-short buffer and assert a range error that is not the serde type.* + +### 14.3 The Deserializer (type witnesses and erasure) + +- **SERDE-5 (MUST).** Every decode operation MUST take an explicit runtime type witness for the target type. A decoder MUST NOT rely on erased compile-time generics, because on an erasure-based runtime that silently yields an untyped map/list which detonates as a cast error on first field access. *Conformance: decode a JSON object into a concrete DTO via the type-witness path; assert the result is the real DTO type and field access returns typed values without a cast failure.* +- **SERDE-6 (MUST).** Parametric decode targets (e.g. `List`) MUST be expressible through a full-generic type carrier that preserves element types across erasure. A format-agnostic decoder that cannot resolve type arguments MUST fail loudly (serde exception) for a genuinely parametric carrier rather than silently decoding into the raw type; a carrier wrapping a plain class MUST still decode via the raw path. *Conformance: with a generics-resolving serde, decode a JSON array into `List` and assert element typing; with a minimal decoder, assert a parametric carrier throws while a plain-class carrier decodes.* +- **SERDE-7 (MUST).** An ergonomic reified/inline decode helper (where the host language offers one) MUST capture the full generic type and route through the generic carrier, not forward only the raw class. *Conformance: call the reified helper with a concrete target and assert normal decoding; rely on the composition proof for a parametric target.* +- **SERDE-8 (MUST).** The generic type carrier MUST capture a concrete, fully-resolved type at construction and MUST reject construction with no type argument or an unresolved type variable (created inside a generic function/subclass where the argument erases to its bound), failing fast with an actionable message. *Conformance: construct with a concrete type → succeeds, exposes the full type and raw class; construct with no argument / inside a generic function / via a generic subclass → each throws.* + +### 14.4 Failure model + +- **SERDE-9 (MUST).** Encode/decode failures MUST surface as the SDK's stable serde exception type (or a subtype). Adapters MUST catch the backing codec's processing failures and rethrow as the serde type, MUST chain the original as the cause, and MUST NOT allow a backing-library exception type to escape the SPI. *Conformance: feed malformed input and an unserializable value; assert the thrown type is the SDK serde type, not the library's, and its cause is the library's exception.* +- **SERDE-10 (MUST).** Write-path failures MUST be a serialization-specific subtype and read-path failures a deserialization-specific subtype, both of the common root, so callers can distinguish direction while catching one base type. *Conformance: assert an unserializable value throws the serialization subtype and malformed input the deserialization subtype, both instances of the root.* +- **SERDE-11 (SHOULD).** Serde failures SHOULD be unchecked/runtime rather than checked/declared, so callers are not forced to wrap every round-trip. On languages without checked exceptions the portable intent is "a normal error, not part of the declared signature." +- **SERDE-12 (MUST).** A genuine stream I/O error raised while reading/writing a caller-owned stream MUST propagate unwrapped as an I/O error and MUST NOT be re-wrapped as a serde exception. Only malformed-input / shape-mismatch / unencodable-value failures are wrapped. *Conformance: inject an I/O failure mid-encode/decode; assert the raw I/O error propagates; inject malformed content and assert it IS wrapped.* +- **SERDE-13 (MUST).** Decoding a wire null literal into a non-null target type MUST fail with a deserialization exception naming the target type, across every decode overload. It MUST NOT return a null that flows through the non-null result and detonates later. *Conformance: decode the literal null into a non-null DTO through each overload; assert every one throws naming the target type.* + +### 14.5 Tristate (PATCH three-state semantics) + +- **SERDE-14 (MUST).** The PATCH tri-state type MUST model exactly three states — Absent (key missing), Null (explicit null), Present (carries a value) — and MUST make the illegal fourth state (Present of a null value) unrepresentable through the public API by bounding Present to non-null values. It SHOULD be covariant in its value type. *Conformance: assert Present rejects null at construction; Absent/Null assignable to any parameterization; exhaustive discrimination via predicates/fold.* +- **SERDE-15 (MUST).** When serializing a tri-state field within an object: Absent MUST omit the key entirely; Null MUST emit the key with a wire null; Present MUST emit the key with the encoded inner value. *Rationale: a PATCH server treats an omitted key as "leave unchanged" and an explicit null as "clear".* *Conformance: serialize the field as Absent/Null/Present; assert no key / `key:null` / `key:value` respectively.* +- **SERDE-16 (MUST).** When deserializing: a missing key → Absent, a present explicit-null → Null, a present value → Present(value) with the inner value's declared element type preserved. *Conformance: decode `{}`, `{"x":null}`, `{"x":value}` → Absent, Null, Present(value).* +- **SERDE-17 (MUST).** A tri-state field with no key on the wire MUST resolve to Absent; in practice this requires the field's declared default to be Absent and the decoder's empty-value fallback to yield Absent, because a missing key is short-circuited by the codec before the decoder's null hook runs. *Conformance: decode an object omitting the field into a DTO whose field defaults to Absent; assert Absent, not Null.* +- **SERDE-18 (SHOULD).** The tri-state type SHOULD provide construction/consumption helpers: factories for absent, explicit-null, present(non-null), and a nullable-to-(present|null) mapper that can never yield Absent; plus a three-way fold, a value-or-null accessor, and is-absent/is-null/is-present predicates. *Conformance: assert the nullable mapper yields Present for non-null and Null for null.* +- **SERDE-19 (MUST).** The default codec configuration MUST wire the tri-state PATCH semantics. An adapter building a serde around a caller-supplied codec MUST register that wiring by default and MAY allow opting out only for a caller that already installed equivalent wiring. Absent this wiring, Absent and Null become indistinguishable on the wire. *Conformance: build from a bare codec with default settings; assert Absent omits the key and Null emits null.* +- **SERDE-20 (SHOULD).** When a tri-state value is serialized with no enclosing object able to omit a key (a top-level value or an array element), the implementation SHOULD degrade gracefully: emit a wire null for both Absent and Null, and for an array element emit null for Absent rather than throwing. *Conformance: serialize a top-level Absent and Null → both render null; deserialize a top-level null → Null; an array element Absent → null.* +- **SERDE-30 (MAY).** The absent and explicit-null sentinels MAY provide a stable, identity-free textual representation (e.g. "Absent", "Null") so logs and assertions do not leak an identity hash. + +### 14.6 Codec configuration policy + +- **SERDE-21 (MUST).** The default decoder configuration MUST reject cross-shape scalar coercions instead of silently reshaping them: string→integer, string→float, string→boolean, empty-string→integer/float/boolean, float→integer (lossy narrowing), boolean→integer, integer→boolean, boolean→float, and any non-string scalar→string. A rejected coercion MUST surface as a deserialization failure. *Conformance: `"5"`→int, `"1.5"`→double, `"true"`→bool, `1.5`→int, `true`→double, and `5`→string all fail.* +- **SERDE-22 (MUST).** The strict-coercion policy MUST still permit representation-preserving conversions: numeric widening of an integer into a floating-point target, an empty string into a textual target, and any well-typed value binding to its matching target. *Conformance: integer→float widens; empty string→string binds to `""`.* +- **SERDE-23 (SHOULD).** The default decoder configuration SHOULD ignore unknown/unexpected fields rather than failing, so a server can add backward-compatible fields ahead of a client model update. *Conformance: decode a document with an extra field; assert success.* +- **SERDE-24 (SHOULD).** The default encoder configuration SHOULD emit date/time values as ISO-8601 strings, not numeric epoch timestamps; whichever form is chosen, the encoding MUST round-trip to the same instant. *Conformance: serialize an instant → ISO-8601 string, deserialize → equality with the original.* +- **SERDE-25 (SHOULD).** A factory building the default codec configuration SHOULD return a fresh, independent instance on each call rather than a shared mutable singleton, because codec instances carry mutable caches that interact poorly with post-construction reconfiguration. *Conformance: invoke the factory twice and assert distinct instances.* +- **SERDE-26 (MUST).** When a serde is built around a caller-supplied codec instance, the SDK MUST NOT mutate the caller's instance during normal construction; it MUST operate on a private copy of the codec engine. If the codec cannot be copied, the implementation MAY fall back to using the supplied instance directly, but this mutating fallback MUST be documented behavior, not silent. *Conformance: pass a codec instance to the factory; assert the serde's engine is not the same reference and that serializing the caller's object afterward shows no SDK-injected behavior change.* +- **SERDE-29 (SHOULD).** A configured serde SHOULD be safe to share across concurrent threads/tasks once configuration is complete and no longer mutated; any per-type sub-serializer caches SHOULD use non-blocking, publication-safe updates rather than coarse locks. *Conformance: exercise one serde from many concurrent workers encoding/decoding distinct values and assert no corruption.* + +### 14.7 Response handlers + +- **SERDE-27 (MUST).** A response-decoding handler MUST stream the response body directly through the deserializer into the target value (without first materializing the whole body), MUST consume and close the response on every path, MUST surface a missing body (e.g. 204) as a serde exception naming the target type, and MUST surface a codec/parse failure as a serde exception chaining the original while letting a genuine mid-stream I/O error propagate unwrapped. *Conformance: handle a valid body → typed value plus one close; a bodyless response → serde exception naming the target; malformed content → serde exception with a non-null cause; a mid-stream I/O error → propagates unwrapped; the response closes in every case.* +- **SERDE-28 (MUST).** A status-aware handler MUST decode the body only on a 2xx status. On 4xx/5xx it MUST throw the mapped HTTP-error exception carrying a bounded, buffered in-memory copy of the error body (so the error body is readable after the live response closes) instead of decoding the error payload as the success type. On any other non-2xx status (1xx, or an unfollowed 3xx such as 304) it MUST close the response and raise a serde exception whose message leads with the status code and preserves conditional/redirect context (ETag / Location). *Conformance: 2xx → decode; 500 (and a non-canonical 599) → the mapped exception with the status code and a readable buffered error body; 304 → serde exception naming the status plus one close.* (See §11 / BODY-30 for the shared 1 MiB error-body cap.) + +--- + +## 15. Instrumentation and Observability + +Observability seams comprise a zero-allocation structured-logging facade, a redaction policy that scrubs credentials before they reach any backend, a diagnostic-context (MDC) allow-list, W3C-Trace-Context-compliant tracing, an HTTP-shaped tracer vocabulary, and a metrics SPI with an inert no-op default. The overriding intent: observability is always safe (never leaks secrets), always cheap when disabled (no hot-path allocation), and never breaks the caller. **That last guarantee is asymmetric:** log-emission failures are caught and swallowed, whereas tracing and metrics calls are NOT defensively wrapped — their safety rests on the SPI contract that those callbacks never throw (OBS-30). A port MUST either honour that contract or add its own guards. + +### 15.1 Structured logging facade + +- **OBS-1 (MUST).** When the requested level is disabled, obtaining a log event and calling its builder methods and terminal emit MUST allocate nothing and produce no output. The facade MUST decide enabled/disabled once, at event-creation time, and return a shared inert event for the disabled case. *Conformance: with the backend level below the event level, assert the returned event is the shared singleton (reference-identical across calls) and that a field/event/cause/log chain emits nothing.* +- **OBS-2 (MUST).** The facade MUST expose exactly four severity levels — ERROR, WARNING, INFO, VERBOSE — mapped onto the backend's ERROR, WARN, INFO, and most-verbose/DEBUG levels. *Conformance: for each level, emit an event and assert it surfaces at the corresponding backend level.* +- **OBS-3 (MUST).** A field key MUST be rejected when empty; a null field value MUST NOT be dropped — it MUST be emitted as the literal string `null`. *Conformance: `field("", x)` raises an argument error; `field(k, null)` yields the literal `null`.* +- **OBS-4 (MUST).** `event(name)` MUST set an authoritative categorisation tag under the reserved key `event`. An empty name MUST clear the tag. When a non-empty tag is set, any `event` key from the global context, folded diagnostic context, or a per-event field MUST be suppressed so the emitted event carries `event` exactly once. *Rationale: JSON appenders produce invalid duplicate-key output otherwise.* *Conformance: set `event("x")` plus an `event` field/global/context entry; assert one `event` key equal to `x`.* +- **OBS-5 (MUST).** When the same field key is contributed by more than one source, precedence MUST be per-event field over global context over folded diagnostic context, and a key MUST appear at most once. *Conformance: provide the same key via all three with distinct values; assert the per-event value is the single emitted value.* +- **OBS-6 (MUST).** Field-value rendering MUST be total (never throw): throwables render as `SimpleClassName: message`; arrays/collections/maps as a bracketed textual form; numeric/boolean/char primitives pass through type-preserving. If a value's own string conversion throws, the facade MUST substitute a diagnostic placeholder. *Conformance: log a value whose string conversion throws; assert the placeholder appears and no exception escapes.* +- **OBS-7 (SHOULD).** A rendered field value SHOULD be truncated to a bounded maximum (reference 8 KiB) with a truncation marker; primitives are exempt. *Conformance: log a string longer than the cap; assert output length equals cap+suffix.* +- **OBS-8 (MUST).** A single log event MUST be emitted at most once; a second terminal emit MUST be a no-op, and this guard MUST be correct under concurrent invocation. Field/tag/cause accumulation need not be thread-safe, but terminal emit MUST be safe from any thread. *Conformance: call emit twice → one output; race two threads on one instance → exactly one output.* +- **OBS-9 (MUST).** A global key/value context configured on the logger MUST attach to every event (subject to OBS-5 precedence). For hot-path efficiency the implementation SHOULD reference the caller-supplied context rather than deep-copying per event; the context is therefore expected to be effectively immutable (a port that copies is still conformant). *Conformance: configure a global field, emit multiple events, assert the field appears on each.* +- **OBS-40 (SHOULD).** A once-per-logger diagnostic SHOULD warn when a caller sets a per-event field colliding with the reserved `event` key, throttled to at most one emission per logger and gated on the verbose level being enabled. Ambient `event` keys from global/diagnostic context defer silently and MUST NOT be warned about. *Conformance: repeatedly emit events with a field named `event` plus `event(name)`; assert one verbose diagnostic per logger.* + +### 15.2 Diagnostic-context allow-list + +- **OBS-10 (MUST).** When folding thread-local diagnostic context into an event, only allow-listed keys MUST be folded. The default allow-list MUST be exactly `{trace.id, span.id}`. A null (absent) allow-list MUST fold every present key (opt-in unfiltered mode). Keys with null values MUST be skipped. *Rationale: prevents arbitrary application context from leaking into SDK-owned events.* *Conformance: with unrelated diagnostic keys set, assert only trace.id/span.id fold by default; with allow-list null assert all fold.* + +### 15.3 Redaction policy + +- **OBS-11 (MUST).** URL userinfo (`user:password@`) MUST always be redacted to a fixed placeholder (`***:***@`), unconditionally and independent of any allow-list. *Conformance: redact a URL with `user:secret@host`; assert the placeholder and neither the username nor password substring appears.* +- **OBS-12 (MUST).** URL query-parameter values MUST be redacted to `***` unless the parameter name (decoded, compared case-insensitively) is allow-listed. The default query allow-list MUST be exactly `{api-version}`. An empty allow-list MUST redact every value. Multi-value keys MUST be treated atomically. Names and the `=` separator MUST be preserved. *Conformance: `?api-version=1&token=abc` (default) → api-version kept, token value `***`; empty allow-list → both redacted.* +- **OBS-13 (MUST).** A URL fragment MUST be scrubbed under the same allow-list as query parameters: `key=value` tokens redacted like query values, a plain fragment with no `=` preserved verbatim. *Rationale: OAuth implicit-flow access tokens ride in the fragment.* *Conformance: `#access_token=SECRET` → token key kept, value `***`; `#section` → unchanged.* +- **OBS-14 (MUST).** URL redaction MUST NOT alter scheme, host, port, or path, and MUST preserve a present-but-empty query (trailing `?`). A `?` inside the fragment MUST NOT be treated as a query delimiter; a trailing `&` (empty final pair) MAY be dropped. *Conformance: redact `http://h/p#a?b=c` and assert no `?` before `#`.* +- **OBS-15 (MUST).** URL redaction MUST be total: on any parse/rebuild failure it MUST return a fixed sentinel (`[malformed url]`) rather than throwing. *Conformance: feed a malformed URL and assert the sentinel and no exception.* +- **OBS-16 (MUST).** A URL arriving as a header value MUST be redacted. A parseable absolute value is redacted like a request URL; a relative/unparseable value MUST keep the path and drop everything after it, appending a fixed `?***` marker whenever the value carried a query OR a fragment. A value with neither MUST be returned verbatim. *Conformance: `/cb?code=SECRET` → `/cb?***`; a plain relative path with no query/fragment is unchanged.* +- **OBS-17 (MUST).** When logging header values, values of URL-valued response headers (at minimum Location and Content-Location) MUST be redacted through the URL-value redactor; other header values pass through unchanged. The redaction policy MUST be shared by the sync and async logging paths so it cannot drift. *Conformance: render a Location header carrying `?code=SECRET` and assert the value is redacted.* +- **OBS-18 (MUST).** Header logging MUST gate which header *names* are logged by an allow-list. A non-allow-listed header MUST NOT have its value logged; per a boolean policy it is either emitted with a fixed `REDACTED` marker or omitted entirely. The default allow-list MUST contain only diagnostic, non-credential headers. *Conformance: log a request with Authorization (not allow-listed) and Content-Type (allow-listed); assert Content-Type value present, Authorization value absent.* +- **OBS-19 (SHOULD).** A transport dropping a caller-set request header it cannot encode SHOULD surface the drop with a configurable verbosity policy offering at least: WARN every occurrence; WARN the first drop per header name then verbose; verbose only. The default SHOULD be once-per-header-name. *Conformance: under once-per-header, repeatedly drop the same name and assert exactly one WARN then verbose lines.* + +### 15.4 Failure containment + +- **OBS-20 (MUST).** Emitting log events around a request MUST NOT fail the request: every log-emission site (request/response/failure events and the body-drain feeding them) MUST catch any exception and re-surface it as a best-effort `http.instrumentation.*` diagnostic, and a secondary failure while emitting that diagnostic MUST be swallowed. The runtime does NOT defensively wrap tracer (span start, scope activation, end) or metrics (counter/histogram) calls; their non-failure guarantee rests on the SPI contract that those callbacks never throw (OBS-30), so a throwing tracer or meter WILL propagate and can fail the request. *Conformance: inject a logger whose emit path throws and assert the request still completes and a best-effort `http.instrumentation.*` event is emitted; separately assert a throwing tracer/meter is NOT caught by the step.* + +### 15.5 Tracing + +- **OBS-21 (MUST).** A Span MUST expose a recording flag; when non-recording, all mutators MUST be inert and `end()` a no-op. `end()` (success and error variants) MUST be idempotent. *Conformance: on a non-recording span assert mutators inert; call `end()` twice and assert no duplicate export.* +- **OBS-22 (MUST).** Activating a span as current MUST return a scope handle that, when closed, restores the previously-active span; the scope MUST be closeable from a try/using construct and MUST restore even when the guarded code throws. *Conformance: nest two scopes; assert the inner restores the outer; throw inside and assert restoration.* +- **OBS-23 (MUST).** Activating a span for log correlation MUST push the trace id and span id onto the thread-local diagnostic context (keys `trace.id`, `span.id`) for the scope's lifetime and restore each to its prior value (or remove) on close. For a non-recording span the push MUST be skipped and activation delegates to plain current-span activation. *Conformance: activate a recording span; assert trace.id/span.id present inside and restored after close.* +- **OBS-24 (MUST).** Thread-local diagnostic context MUST be bridgeable across async thread boundaries via an immutable snapshot: capture on the originating thread, reinstall on the executing thread for a block's duration, and restore the prior context afterward (including on exception). The snapshot MUST be immutable/shareable. *Conformance: capture on thread A, run a block on thread B via the bridge, assert the captured keys visible inside and B's original context restored after (including on throw).* +- **OBS-25 (MUST).** Tracing abstractions MUST provide allocation-free no-op defaults used when tracing is disabled: a no-op tracer returning a shared no-op span, a no-op span whose current-scope is cached, a no-op instrumentation context with all-invalid sentinels, and a no-op HTTP-tracer/factory. Selecting a no-op path MUST NOT allocate per call. *Conformance: with no tracer installed, start/end spans in a loop and assert the same singletons and no per-iteration allocation.* + +### 15.6 Trace context (W3C) + +- **OBS-26 (MUST).** An instrumentation/trace context MUST expose W3C-compliant identifiers: a trace id, a span id (16 lowercase hex chars), trace flags (a two-hex-char byte), and a trace-state list, plus validity and remoteness flags. The reserved invalid sentinels MUST be a trace id of 32 hex zeros, span id of 16 hex zeros, trace flags `00`, and empty trace-state; an all-zero trace/span id MUST be treated as invalid. *Conformance: assert sentinel values match the W3C reserved forms and lengths; a context built from all-zero ids reports invalid.* +- **OBS-27 (MUST).** Trace-id generation MUST support at least a W3C flavour (128-bit as 32 lowercase hex), a Datadog flavour (64-bit unsigned decimal), and a no-op flavour yielding the invalid sentinel. Generation MUST NOT produce the reserved all-zero id — a zero draw MUST be coerced non-zero. *Conformance: generate many W3C ids and assert each is 32 lowercase hex and never all-zero; Datadog ids decimal, non-zero, within 64-bit unsigned range.* + +### 15.7 HTTP-tracer vocabulary + +- **OBS-28 (SHOULD).** The SDK SHOULD provide an HTTP-shaped tracer vocabulary richer than start/end: operation started/succeeded/failed; per-attempt started/failed (with next-delay)/retries-exhausted; and transport milestones (request URL resolved, connection acquired, request sent with byte count, response headers received, response received with byte count). Every method SHOULD default to a no-op so adding an event is non-breaking. *Conformance: implement a tracer overriding a subset; drive an operation with retries and assert the expected callbacks fire.* +- **OBS-29 (MUST).** HTTP-tracer lifecycle ordering MUST hold: operationStarted once at the start; operationSucceeded and operationFailed mutually exclusive and each once at the end; attempt events may fire multiple times; retries-exhausted (when it fires) is immediately followed by operationFailed with the same throwable. One tracer instance corresponds 1:1 to a single logical operation. *Conformance: drive a succeeding and a failing (retry-exhausted) operation through a conformant emitter and assert the ordering and the exhausted→failed pairing.* +- **OBS-30 (MUST).** Tracer and HTTP-tracer callbacks MUST be safe to invoke concurrently and from transport threads, and implementations MUST NOT throw from any callback. The metrics instruments MUST likewise be safe to call concurrently and MUST NOT throw. The runtime does not defensively catch these callbacks (OBS-20), so violating must-not-throw breaks the caller's request. *Conformance: invoke callbacks concurrently and assert no corruption; assert a throwing callback propagates (verifying conformant implementations never throw).* + +### 15.8 Metrics + +- **OBS-31 (MUST).** The metrics SPI MUST expose a Meter manufacturing at least a monotonic integer counter and a floating-point histogram, each accepting per-measurement key/value attributes. The default Meter MUST be a no-op that discards every measurement and returns shared instrument singletons, and the core MUST NOT pull a metrics runtime into its dependencies. *Conformance: with the default meter, record measurements and assert no output, shared instrument identity, and no metrics runtime on the classpath.* +- **OBS-32 (SHOULD).** Metric names/descriptions/units SHOULD follow OpenTelemetry semantic conventions and UCUM unit symbols. The default HTTP instrumentation SHOULD emit a request counter `http.client.request.count` (unit `{request}`) and a latency histogram `http.client.request.duration` (unit `ms`), tagged with method and either status code (success) or error type (failure). *Conformance: run a success and a failure with a recording meter; assert the two instrument names/units and the attribute sets.* +- **OBS-33 (MUST).** A monotonic counter MUST document that only non-negative increments are valid (negative deltas undefined, caller's responsibility), and the core instrument MUST NOT validate on the hot path. A histogram MUST tolerate any input without throwing; handling of non-finite values is delegated to concrete adapters. *Conformance: record NaN/Infinity into the no-op histogram and assert no throw.* + +### 15.9 Log level, body preview, and event vocabulary + +- **OBS-34 (MUST).** HTTP logging granularity MUST be selectable across at least none, headers-only, and headers-plus-body, defaulting to none. At none, request/response log events MUST NOT be emitted and body capture occurs only at the body level. Span lifecycle AND metric recording run on every request independent of the log level, so "none" silences log events without disabling tracing or metrics. *Conformance: at none assert no request/response events but the span still starts/ends and the counter/histogram still record; at body level assert body preview fields present.* +- **OBS-35 (SHOULD).** A log-level value SHOULD be resolvable from layered configuration (explicit override → environment variable → normalized system property → default) with tolerant parsing (case-insensitive, whitespace-trimmed), falling back to a caller-supplied default (itself defaulting to "none"). The SDK MUST NOT bake in a default config key name. *Conformance: resolve ` Headers ` and `HEADERS` to the headers level; an unset/empty/garbage value to the default.* +- **OBS-36 (MUST).** Under body logging, body capture MUST be bounded to a configurable preview size (reference default 8 KiB) and MUST NOT buffer the whole body: a body larger than the cap MUST still stream in full to the caller (replay captured prefix then continue from the live tail) while only the preview occupies memory. *Conformance: send a body larger than the cap; assert the caller receives every byte, the preview length is capped, and the size field reflects the preview.* +- **OBS-37 (SHOULD).** For unknown-length (streaming/chunked) response bodies, the async logging path SHOULD skip body capture entirely and stream the body unwrapped, so a slow producer cannot block the completion thread. *Conformance: drive an async request with an unknown-length body and assert no preview is captured and the completion thread is not blocked.* +- **OBS-38 (SHOULD).** A captured body preview SHOULD be charset-aware for text (decoding with the media type's charset, falling back to UTF-8) and binary-safe for non-text (a size-only marker such as `[binary N bytes captured]`). Decoding MUST NOT throw — malformed/truncated input yields replacement characters. Empty input yields an empty preview. *Conformance: render an ISO-8859-1 text body (decodes), a binary body (size-only marker), a truncated multibyte body (no throw).* +- **OBS-39 (MUST).** The emitted structured event names and field keys MUST be stable: at minimum events `http.request` and `http.response`, carrying `http.request.method`, `url.full` (redacted), `http.response.status_code`, `http.response.duration_ms`, and content-length/header fields; a failure emits an `http.response` event with `error.type` and the throwable cause. The logged `url.full` MUST always be the redacted URL. *Conformance: capture events for a success and a failure and assert the documented names/keys and that url.full is the redacted form.* + +--- + +## 16. Configuration + +The layered configuration model resolves string-keyed values through a fixed precedence chain and derives reconfigured instances copy-on-write. Utility primitives — an injectable clock, non-blocking delay/cancellation helpers, an environment-driven proxy model, RFC 1123 dates, a non-blocking UUID generator, a shared retryability classifier, a build/runtime identity descriptor, and deep value equality — round out the subsystem. The porting goal is behavioral parity: same precedence, same never-throw lookup, same immutability, and the same substitutable env/property/time seams so conformance tests run hermetically. + +### 16.1 Layered lookup + +- **CFG-1 (MUST).** A value lookup MUST resolve in strict order: (1) an explicit override for the exact key, (2) the environment source queried by the exact key name, (3) the system-property source queried by the *normalized* key name, (4) the caller-supplied default (which MAY be absent). *Conformance: register an override, distinct env, and distinct property values; assert override wins, then env, then property, then default as each higher layer is removed.* +- **CFG-2 (MUST).** An environment value that is present but empty MUST be treated as absent, so the lookup falls through to the property layer. *Conformance: env returns empty, property returns a value; assert the property value resolves.* +- **CFG-3 (MUST).** The property layer MUST be queried under a normalized key derived by lowercasing and replacing every underscore with a dot (`MAX_RETRY_ATTEMPTS` → `max.retry.attempts`). The override and environment layers MUST use the original name. *Conformance: set the property only under the dotted-lowercase form; assert `get(SCREAMING_NAME)` resolves it.* +- **CFG-4 (MUST).** A separate raw property accessor MUST look up by the exact name, without the env→property normalization, so camelCase property-only keys (e.g. `https.proxyHost`) resolve with casing preserved. *Conformance: set the property for `https.proxyHost`; assert the raw accessor returns it and the normalizing accessor does not.* +- **CFG-38 (MUST).** The typed accessors (integer, boolean, duration) MUST resolve the raw value through the *same* layered lookup as the string accessor before parsing; they MUST NOT read only the override map or skip the env/property layers. The typed default applies only when the layered lookup yields no value. *Conformance: supply a value only via the env seam and assert `getInt`/`getBoolean`/`getDuration` resolve it, not the typed default.* + +### 16.2 Never-throw typed accessors + +- **CFG-5 (MUST).** Typed accessors MUST NOT throw on missing or unparseable values: the integer accessor returns the default when absent or not a valid integer; the duration accessor returns the default on any parse failure. Negative integers are valid and returned as-is. *Conformance: `getInt` on `"not-a-number"` → default; on `"-5"` → -5.* +- **CFG-6 (MUST).** The boolean accessor MUST be strict: only case-insensitive `true`/`false` are recognized; anything else (`1`, `0`, `yes`, `no`, `on`, `off`) falls through to the default. *Conformance: `getBoolean("TRUE")` parses; `getBoolean("1")` / `"yes"` / `"on"` return the supplied default.* +- **CFG-7 (MUST).** The duration accessor MUST accept ISO-8601 durations (leading `P`/`p`), shorthand `` (units ms, s, m, h, d, case-insensitive), and a bare number interpreted as *milliseconds*. A negative duration MUST be rejected (return default), and an unknown unit MUST return the default. *Conformance: `PT5S`→5s, `500ms`→500ms, `1000`→1s, `PT-5S`→default, `5x`→default.* + +### 16.3 Immutability and derivation + +- **CFG-8 (MUST).** A built configuration MUST be immutable and safe to share without external synchronization; the override map MUST be defensively copied at build time so later builder mutation cannot alter a built instance. *Conformance: build, mutate the builder, rebuild; assert the first instance is unchanged.* +- **CFG-9 (MUST).** Deriving a reconfigured configuration MUST be copy-on-write: it produces a new instance with the mutator applied while leaving the receiver unchanged. The override map MUST be copied before the mutator runs; the environment and property source seams MUST be inherited by reference unless the mutator replaces them. *Conformance: derive adds/overrides a key on the copy only; a source-replacing mutator detaches only the copy.* +- **CFG-10 (MUST).** Removing an override MUST drop only the override layer: a subsequent lookup falls through to env/property/default as if the override never existed. Removal MUST NOT force the key to resolve to null, and removing a key with no override MUST be a no-op. *Conformance: derive with `remove` of an inherited override while the env seam supplies a value; assert the derived copy resolves the env value.* +- **CFG-11 (MUST).** The environment and property sources MUST be substitutable seams (injectable functions from key name to optional string), so tests supply hermetic lookups without touching the real environment. Production defaults MUST delegate to the platform environment and system properties. *Conformance: build with custom functions and assert lookups route through them.* +- **CFG-12 (SHOULD).** Configuration builders SHOULD be usable single-threaded only; the immutability guarantee applies to the built configuration, not an in-progress builder. +- **CFG-13 (SHOULD).** A process-wide global configuration slot SHOULD be provided with last-write-wins replacement and safe publication, defaulting to an empty configuration. *Conformance: default global lookup returns the default; after set, the getter returns the same instance.* +- **CFG-14 (SHOULD).** The subsystem SHOULD expose stable well-known key constants for the retry-attempt cap, log level, and standard proxy variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY). +- **CFG-37 (MUST).** Passing a null/absent required argument to a mutating configuration operation (override key or value, source function, derive mutator, global-config setter) MUST fail fast rather than storing a null. Documented-nullable optional slots (proxy credentials, challenge handler, lookup default) MAY be null. In a language without null-safety a port MUST add explicit validation. *Conformance: `put(null,..)`/`remove(null)`/`derive(null)` throw; `get(name, default=null)` accepts null.* + +### 16.4 Clock and async primitives + +- **CFG-15 (MUST).** The time abstraction MUST be an injectable seam exposing three operations: current wall-clock instant, a monotonic elapsed-time counter, and a blocking interruptible sleep. A shared platform-backed default MUST be provided. Time-dependent logic SHOULD route through this seam so tests can drive time deterministically. *Conformance: inject a fake clock and assert behavior advances only when the fake advances.* +- **CFG-16 (MUST).** The monotonic counter MUST be non-decreasing and used only for measuring elapsed durations between its own readings; its absolute value is not meaningful. The wall-clock reading MAY move backwards and MUST NOT be used for elapsed-time measurement. *Conformance: assert two successive monotonic readings are non-decreasing.* +- **CFG-17 (MUST).** `sleep` MUST reject a negative duration, MUST allow a zero duration (returning promptly), and MUST honor cooperative cancellation: when interrupted mid-sleep it MUST re-assert the interrupt/cancellation status before propagating. *Conformance: `sleep(negative)` throws; interrupt a sleeping thread and assert the interruption propagates with the flag still set.* +- **CFG-18 (SHOULD).** The async layer SHOULD provide a scheduled non-blocking delay yielding a future that completes after a non-negative duration on a scheduler without blocking a thread. Zero completes immediately, negative is rejected, and cancelling the future MUST cancel the underlying scheduled task. *Conformance: `delay(zero)` completes immediately; cancelling the future cancels the scheduled task.* +- **CFG-19 (SHOULD).** When surfacing the cause of a failed async operation, the subsystem SHOULD unwrap the platform's async-completion wrapper exceptions to the original throwable, terminating on the first non-wrapper, a null cause, or a detected cycle. A non-wrapper is returned unchanged. *Conformance: unwrap of a wrapper-around-IOException returns the IOException; a self-cyclic chain terminates.* +- **CFG-20 (SHOULD).** The subsystem SHOULD provide an interruptible-task future: running a task on an executor such that cancel-with-interrupt interrupts the running worker while cancel-without does not. A queued or finished task MUST NOT be interrupted, and the worker's interrupt state MUST be cleared before it returns to its pool. Rejected submission MUST be delivered through the future, never thrown synchronously. *Conformance: cancel(true) interrupts a running blocking task; cancel(false) does not; submitting to a shut-down executor completes the future exceptionally.* +- **CFG-21 (MUST).** When an interruptible-task future has already been cancelled and the task nonetheless produced a closeable result, that result MUST be closed on the discard path (best-effort, swallowing close failures). The close helper MUST be null-safe. *Conformance: cancel a future whose task returns a closeable spy after cancellation; assert the spy closed exactly once.* + +### 16.5 Proxy model + +- **CFG-22 (MUST).** The proxy model MUST be immutable and carry the proxy type (HTTP, SOCKS4, SOCKS5), socket address, an ordered list of non-proxy host glob patterns, optional credentials, an optional challenge-handler slot, and an explicit bypass-all flag. Its string rendering MUST mask credentials. *Conformance: construct with credentials; assert the string form masks them.* +- **CFG-23 (MUST).** The host-bypass decision MUST short-circuit to true when bypass-all is set; otherwise return true iff the host matches any configured glob. Glob conversion MUST treat `*` as "any run", `?` as "one character", escape regex metacharacters, require a full-string match, and match case-insensitively. Patterns SHOULD be compiled once at construction. *Conformance: `*.internal.example.com` matches subdomains case-insensitively but not the apex; a `.` matches literally.* +- **CFG-24 (MUST).** Resolving proxy options from configuration MUST follow this precedence and MUST NOT throw on malformed input (invalid config → null + warning): (1) system properties first — host is `https.proxyHost` preferred over `http.proxyHost`, and the port MUST come from the *same* layer as the chosen host; credentials read only from `https.proxyUser`/`https.proxyPassword` (no http.* fallback); (2) otherwise the env URL `HTTPS_PROXY` preferred over `HTTP_PROXY`, parsed as `scheme://user:pass@host:port`. If no proxy is configured or config is invalid, resolution MUST return null. *Conformance: only http.proxyHost/Port set but https.proxyUser/Password also set → credentials still from the https.* properties.* +- **CFG-25 (MUST).** The proxy port MUST be explicit and within 0..65535; a missing, non-numeric, or out-of-range port MUST yield null (no default-port guessing). An absent port in a proxy URL MUST be treated as invalid. *Conformance: a proxy URL without a port → null; port 70000 → null.* +- **CFG-26 (MUST).** The non-proxy host list MUST be resolved with the system property (pipe-separated) winning over the environment variable (comma-separated). Both MUST honor a backslash escape preceding the literal separator and MUST trim tokens. The observable order is split → drop empty → unescape → trim, so a whitespace-only fragment is retained as an empty token. *Conformance: `a\|b|c` → `[a|b, c]`; `a\,b,c` → `[a,b, c]`.* +- **CFG-27 (MUST).** When the resolved non-proxy configuration is exactly a single bare `*`, it MUST be interpreted as bypass-all (resolution returns null so the caller routes directly), represented by the explicit bypass-all flag, NOT by placing `*` as a literal entry. A `*` inside a multi-entry list is a normal any-host glob. *Conformance: `NO_PROXY="*"` → null; `["*","x"]` → two globs.* +- **CFG-28 (MAY).** A convenience resolver MAY read proxy options from the global configuration, but the environment MUST be consulted only when a resolver is explicitly invoked — nothing may read proxy configuration implicitly at startup. + +### 16.6 Dates, identifiers, and value equality + +- **CFG-29 (MUST).** RFC 1123 date *formatting* MUST emit the canonical HTTP-date form with a zero-padded two-digit day-of-month and a literal `GMT`, rendered in UTC (e.g. `Sun, 06 Nov 1994 08:49:37 GMT`). *Conformance: format a known instant and assert the exact canonical string.* +- **CFG-30 (MUST).** RFC 1123 date *parsing* MUST be tolerant in these ways: month names case-insensitive; the zone token accepts `GMT`, `UTC`, `+0000`, `+00:00` (all normalized to zero offset); and the leading weekday token is informational only and MUST NOT be validated against the date (it is stripped, not parsed). *Conformance: `Mon, 06 Nov 1994 08:49:37 GMT` (wrong weekday) parses; `UTC`/`+0000`/`+00:00` parse to the same instant.* +- **CFG-31 (MUST).** RFC 1123 parsing MUST be strict on the day-of-month-onward grammar: blank input MUST fail, and a form missing the comma after the weekday MUST fail. *Conformance: `parse("")` throws; `parse("Mon 01 Jan 2024 00:00:00 GMT")` (no comma) throws.* +- **CFG-32 (MUST).** The non-blocking UUID generator MUST produce type-4 UUIDs with the correct RFC 4122 layout (version 4, IETF variant), MUST be usable concurrently without shared mutable state, MUST use a non-blocking (per-thread) PRNG, and callers MUST treat the output as NON-cryptographic. *Conformance: generated UUIDs report version 4 and IETF variant; a large batch has no collisions.* +- **CFG-33 (MUST).** Deep value-equality helpers MUST compare by content: arrays element-by-element (object arrays recurse for nested/multi-dimensional arrays; primitive arrays compare by element value), non-arrays fall back to ordinary equality. Both helpers MUST be null-safe (two nulls equal; null hashes to zero), and equals and hashCode MUST be mutually consistent. *Conformance: two equal-content byte arrays are equal with matching hashes; two nulls equal and hash to 0.* +- **CFG-34 (MUST).** Deep equality MUST follow floating-point array semantics where NaN equals NaN and +0.0 does not equal -0.0 (for primitive and boxed float/double arrays), with hashing matching. An object array and a primitive array of the same numeric values MUST NOT be equal. *Conformance: `[NaN]==[NaN]` true; `[0.0]==[-0.0]` false; a boxed-Integer array not equal to an int array of the same values.* +- **CFG-35 (SHOULD).** A shared retryability classifier SHOULD exist treating 408, 429, and all 5xx except 501/505 as retryable, and treating a throwable as retryable iff it or any cause in its chain is an IO/timeout error (cause-chain traversal cycle-safe). Where implemented, this exact status set is a hard contract. *Conformance: `isRetryable(408/429/500/503)` true, `501/505/404` false; a wrapped IO/timeout exception retryable.* +- **CFG-36 (SHOULD).** A static build/runtime descriptor SHOULD expose the SDK version and host runtime identity resolved once at load time, each falling back to a non-blank `unknown` when unavailable, and SHOULD provide a default ordered identity-token list (SDK token then runtime token). Every token MUST be non-blank. *Conformance: with version metadata absent, the version reads `unknown`; the default tokens contain no empty token.* + +--- + +## 17. Transport Adapter Conformance Contract + +The SDK is an HTTP-client toolkit, not an HTTP client: it owns redirect, retry, auth, and logging in its pipeline and delegates only "send one request, get one response" to a transport (see §7 / SEAM-11, SEAM-16). A conforming transport disables the native client's own redirect/retry so the pipeline is the single authority; faithfully maps between the SDK's immutable models and the native client's; classifies cancellation, timeout, and no-response failures into the SDK's canonical exception contract; propagates cancellation into the native client bidirectionally; keeps the caller's Content-Type authoritative; drops headers the native client cannot encode rather than failing the send; never touches the lifecycle of a BYO client; and writes request bodies replay-safely. Where a behavior exists in only one reference transport (OkHttp, java.net.http) the requirement is scoped accordingly. + +### 17.1 Pipeline authority + +- **TRANSPORT-1 (MUST).** An SDK-managed (builder-constructed) transport MUST disable the native client's automatic redirect following, and the follow-redirects knob's default MUST be off. *Conformance: enqueue a 302 with Location; assert the returned response is the raw 302, not the redirected target.* +- **TRANSPORT-2 (MUST).** Where the native client has a built-in connection-failure/automatic retry feature, an SDK-managed transport MUST disable it. *Conformance: with a single-use body and a first-attempt connection failure, assert the native client does not silently re-send.* + +### 17.2 Cancellation and timeout classification + +- **TRANSPORT-3 (MUST).** On the sync path a caller-initiated cancellation MUST surface as a terminal, non-retryable interrupt-shaped I/O exception with the runtime's cancellation signal preserved; it MUST NOT be repackaged as the retryable transport-failure exception. Discrimination MUST be out-of-band (the runtime's cancellation state), not by matching messages. *Conformance: force a mid-call cancellation then a transport failure; assert the thrown exception is the interrupt type, not the retryable type, and the cancellation signal remains observable.* +- **TRANSPORT-4 (MUST).** A read/response timeout MUST be classified as a RETRYABLE transport failure (the canonical `NetworkException`) and MUST NOT set the caller's cancellation flag, even when the runtime represents a timeout with the same exception family as an interrupt. *Conformance: configure a short timeout against a slow server; assert the retryable type and a clear cancellation flag afterward.* +- **TRANSPORT-5 (MUST).** A per-call timeout override MUST apply to that single call, overriding the configured default for that call only and leaving the shared native client untouched; a null override leaves the configured default in force. *Conformance: two concurrent calls with different per-call timeouts; assert each is bounded by its own value.* +- **TRANSPORT-6 (SHOULD).** A transport SHOULD NOT let a positive per-call timeout be silently reduced to zero by unit truncation. Where the native timeout API is coarser than the requested duration AND treats zero as "no timeout", a positive sub-resolution duration MUST be clamped up to the smallest finite deadline rather than truncated to zero. *Conformance: on such a transport, set a per-call timeout smaller than the native resolution against a slow server; assert it still times out rather than hanging.* +- **TRANSPORT-7 (MUST).** Cancelling the async response future MUST propagate cancellation into the in-flight native exchange so its connection/resources are released promptly. *Conformance: cancel an in-flight future and assert the native call is cancelled.* +- **TRANSPORT-8 (MUST).** Where the native client can surface a cancellation originating inside it while the SDK future is still live, that cancellation MUST complete the future with a terminal, non-retryable cancellation-shaped exception, not the retryable type; a genuine timeout on the same path MUST still complete retryable. (Implemented by the OkHttp reference; a transport with no internal-cancel path need not.) *Conformance: trigger a native-internal cancel and assert the terminal type; a timeout on the same path still completes retryable.* +- **TRANSPORT-9 (MUST).** If a native response is delivered after the SDK future has already completed or cancelled (the adaptation race), the adapted response MUST be closed so its connection is returned to the pool. *Conformance: settle the future before the response finishes adapting; assert the dropped response's connection is released.* + +### 17.3 Header and body mapping + +- **TRANSPORT-10 (MUST).** The caller's explicit request Content-Type MUST remain authoritative and MUST NOT be overwritten by a body-derived media type. A body-derived Content-Type MUST be emitted only when the caller set none (matched case-insensitively). *Conformance: (a) body media type X with explicit Content-Type Y → Y on the wire; (b) same body with no explicit header → X on the wire.* +- **TRANSPORT-11 (MUST).** Headers the native client computes from the body/connection (at minimum Content-Length, Host, Transfer-Encoding; plus any the native client rejects outright, e.g. Connection/Expect/Upgrade on java.net.http) MUST be dropped before dispatch. The transport SHOULD additionally log each drop at verbose. The exact drop set is transport-specific (OkHttp does not drop Connection). *Conformance: send a bogus Content-Length/Host plus a pass-through header; assert the framing headers are recomputed and the pass-through survives.* +- **TRANSPORT-12 (MUST).** A header valid at the SDK model layer but rejected by the native client's stricter wire grammar MUST be dropped for that header only; the resulting native exception MUST NOT escape the send contract. The rest of the headers and body MUST still be dispatched, on both sync and async paths. *Conformance: send a model-valid non-token header name plus a normal header; assert send does not throw / the future completes normally, the bad header is absent, the normal header present.* +- **TRANSPORT-13 (SHOULD).** A transport SHOULD expose a configurable policy for how such header drops are logged (every drop loudly; first per name loudly then quiet, default; all quiet). The per-name dedup mode MUST be case-insensitive and bounded so an attacker cannot grow it without limit. *Conformance: under once-per-header assert the same name warns once then goes quiet, a different name warns once.* +- **TRANSPORT-14 (MUST).** Inbound response headers MUST be copied leniently enough that a single malformed header does not fail the whole response: a control byte in a value, or a control/non-ASCII byte in a name, MUST drop only that header (logged at verbose) while the body and remaining headers are still delivered. A transport SHOULD preserve a non-ASCII/obs-text byte in a value rather than stripping it. *Conformance: an obs-text value is preserved; a control-byte header is dropped and the body still reads.* + +### 17.4 Lifecycle and ownership + +- **TRANSPORT-15 (MUST).** `close()` MUST be ownership-aware: it releases only resources the transport itself created (native client, dispatcher/executor, pool, cache, any SDK-created executor). A BYO native client and its resources MUST NOT be shut down or mutated, so the caller may keep using it after the transport is closed. *Conformance: wrap a BYO client, close the transport, assert the client's executor/pool remain usable; build an owned client, close, assert its executor is shut down.* +- **TRANSPORT-16 (MUST).** `close()` MUST be idempotent and MUST NOT block on native shutdown in a way that discards the caller's cancellation/interrupt state; it uses non-blocking shutdown (no unbounded await). *Conformance: call close several times → no exception and stable state; assert close does not clear a pre-existing cancellation flag.* +- **TRANSPORT-17 (MUST).** A non-replayable (single-use) request body MUST be written to the wire exactly once; the transport MUST prevent the native client from re-writing it (e.g. by reporting the body as one-shot) and MUST NOT itself trigger a second write. A replayable body MAY be re-written. *Conformance: send a single-use body and count native body writes == 1.* +- **TRANSPORT-18 (MUST).** When the native body API drives writes through a re-subscribable producer (so a native internal resend — proxy-auth 407, GOAWAY — re-reads the body), the transport MUST make each subscription produce identical bytes: a non-replayable body is buffered once into a replayable copy, and if that buffering fails mid-write the send MUST fail with the transport-failure type rather than shipping a truncated body. *Conformance: drive a 407-then-success re-subscription of a large streaming body; assert both subscriptions carry the full identical bytes; force buffering to fail mid-write and assert the send fails with the transport-failure type.* +- **TRANSPORT-19 (SHOULD).** When a streaming-body subscription is acquired but abandoned (connect failure, early cancellation), the transport SHOULD cancel/unblock its producer so no writer thread or file handle is stranded; teardown MUST be idempotent. *Conformance: open a streaming subscription, do not drain it, close the read end; assert the producer thread unblocks and resources free.* + +### 17.5 Failure and response mapping + +- **TRANSPORT-20 (MUST).** Any transport failure that produced no HTTP response (connection refused, DNS/TLS failure, peer reset, connect/read timeout) MUST surface as the SDK's canonical retryable transport-failure exception, which MUST be a subtype of the platform I/O-error type and MUST report itself retryable. *Conformance: connect to a dead port; assert both sync throw and async exceptional completion carry the retryable type with isRetryable true.* +- **TRANSPORT-21 (MUST).** On the async path, a failure before dispatch (request adaptation rejecting a request, a synchronous dispatch rejection, an adapter bug) MUST be delivered through the returned future, not thrown synchronously. Only truly fatal runtime errors may propagate synchronously. *Conformance: build a request the adapter rejects synchronously; call sendAsync and assert a future already completed exceptionally, not a synchronous throw.* +- **TRANSPORT-22 (MUST).** If adapting a live native response throws at any point after the native response (and its socket) are live, the transport MUST close the native response before propagating, on both sync and async paths. *Conformance: force adaptation to throw (e.g. no I/O provider installed); assert the connection is released despite the thrown error.* +- **TRANSPORT-23 (MUST).** The async send MUST NOT complete its future with a null response on success; a transport with no response MUST complete exceptionally. *Conformance: across success and failure, assert the future never yields a null value.* +- **TRANSPORT-24 (MUST).** The response status code MUST be mapped totally: any code the server returns, including vendor/non-standard codes (499, 520-526, 530), MUST be surfaced faithfully, and such a response and its body MUST remain readable and closeable. *Conformance: return a 520 with a body; assert status 520, the body reads, and closing releases the connection.* +- **TRANSPORT-25 (MUST).** The response body MUST be exposed as a lazily-read stream, not pre-buffered, and closing the SDK response MUST cascade to close the native body and release the connection. The caller owns closing the response. *Conformance: stream a multi-megabyte response and assert byte-exact round-trip; assert closing returns the connection.* +- **TRANSPORT-26 (MUST).** A body-less request MUST be valid for any method the model permits. Where the native client rejects a null body for a body-requiring method (POST/PUT/PATCH), the transport MUST substitute a zero-length body (with `Content-Length: 0`) instead of failing; for body-forbidden methods (GET/HEAD/TRACE/CONNECT) it MUST NOT attach a body. *Conformance: a body-less POST/PUT/PATCH dispatches with an empty body and `Content-Length: 0`; a body-less GET dispatches with no body.* +- **TRANSPORT-27 (SHOULD).** An unparseable or absent inbound Content-Type SHOULD be downgraded to "no media type" rather than failing the response; an absent/invalid Content-Length SHOULD map to the unknown-length sentinel (-1). *Conformance: a malformed Content-Type and non-numeric Content-Length still let the body read, with null media type and unknown length.* +- **TRANSPORT-28 (SHOULD).** A transport SHOULD stream a file-backed request body directly from the file (honoring start position and byte count) on a zero-copy path where supported, and MUST treat a file body as replayable so it can be re-sent. *Conformance: upload a file body with a non-zero position and partial count; assert exactly that byte range reaches the wire.* +- **TRANSPORT-29 (MUST).** A transport instance MUST be safe for concurrent send calls from multiple threads and MUST be effectively immutable after construction; all per-request state MUST be confined to local scope or the returned response graph. *Conformance: fire many concurrent sync and async calls through one transport and assert each response matches its own request.* +- **TRANSPORT-30 (SHOULD).** When the SDK's proxy configuration carries a feature the native client cannot honor, the transport SHOULD make the limitation discoverable rather than silently misbehaving, and MUST NOT leak credentials: a custom (non-Basic) proxy challenge handler SHOULD be surfaced with a WARN and proxy auth SHOULD fall back to Basic; proxy credentials MUST NOT be logged and MUST NOT be answered to an origin-server (401) challenge — only to a matching proxy (407). *Conformance: configure a proxy with a custom challenge handler; assert normal requests still route (no throw) and a discoverable warning; assert proxy credentials never appear in logs and are not sent to a 401.* + +--- + +## 18. Asynchronous Runtime Adapter Contract + +This defines the portable contract an async-runtime adapter must honor when bridging the SDK's async HTTP transport SPI to a host runtime's concurrency primitives. The interchange point is a single canonical completion future (§7 / SEAM-17) that carries exactly one success value or one failure, and every ecosystem facade (coroutines, reactive Mono/Flux, event-loop futures, virtual threads) bridges to and from it. + +### 18.1 Completion and failure delivery + +- **ASYNC-1 (MUST).** The async transport contract is a single-value completion future that yields exactly one Response on success or completes with exactly one failure. On the success path it MUST deliver a non-null Response; an implementation with no response MUST complete via the failure channel rather than deliver a null/absent value. *Conformance: a stub transport with no response completes exceptionally, never with null; a normal call completes with the exact Response.* +- **ASYNC-2 (MUST).** Every failure detectable while constructing the async operation (request-adaptation errors, worker-pool rejection) MUST be delivered through the future's failure channel, never thrown synchronously. *Conformance: submit through a shut-down executor; assert the returned future completes exceptionally with the rejection.* + +### 18.2 Cancellation modes + +- **ASYNC-3 (MUST).** When an async operation is backed by a blocking task on a worker thread, cancellation MUST distinguish cancel-with-interrupt (interrupts the worker running the in-flight task) from cancel-without-interrupt (cancels the logical operation without interrupting). A task still queued or already finished MUST NOT be interrupted. *Conformance: cancel-with-interrupt → the task observes an interrupt; cancel-without → it does not; cancel before dequeue → never runs/interrupts.* +- **ASYNC-4 (MUST).** Interrupt delivery MUST be ordered so a stale interrupt cannot poison a pooled thread: the cancel path publishes an "interrupt in flight" marker *before* reading the worker, the worker's return-to-pool step blocks until that marker clears, and after the task ends the worker clears its own interrupt flag before reuse. A worker already returned to its pool MUST NOT receive an interrupt aimed at a completed call. *Conformance: concurrency stress racing cancel-with-interrupt against completion on a small pool with a sentinel unrelated task; assert the sentinel is never interrupted and every reused thread hands back with a clear flag.* +- **ASYNC-5 (MUST).** If a worker computes a closeable result but the future was already terminated so the value can never be delivered, the adapter MUST close that orphaned value exactly once; whoever loses the produce/terminate race performs the close. *Conformance: cancel the future in the window after the worker produces a Response but before delivery; assert `close()` invoked exactly once.* +- **ASYNC-6 (MUST).** Cancellation MUST propagate bidirectionally across each adapter: cancelling the runtime-native primitive (subscription, promise, coroutine/job) MUST cancel the underlying canonical future, and cancelling the canonical future MUST reach the runtime primitive / native transport call. *Conformance: per adapter, cancel via the native primitive and assert the future observes cancellation; cancel the future and assert the native primitive terminates.* +- **ASYNC-7 (SHOULD).** Each adapter chooses whether its native cancellation maps to interrupt-mode or non-interrupt-mode, and that choice determines whether an in-flight blocking call is aborted. A port SHOULD preserve and document, per adapter, whether cancelling through a runtime aborts a blocking transport or lets it run to completion. *Conformance: per adapter, wrap a blocking client that ignores vs honors interruption and assert the documented outcome.* + +### 18.3 Logging-context propagation + +- **ASYNC-8 (SHOULD).** Adapters that move work/callbacks onto another thread SHOULD propagate the caller's diagnostic logging context across the hop (capture on the boundary thread, reinstate on the executing/callback thread) so post-hop log events retain correlation. This is an observability guarantee, not a functional one: an adapter that omits it still executes exchanges correctly. *Conformance: set a context entry on the caller thread; with propagation enabled, assert a log emitted on the worker carries it.* +- **ASYNC-9 (MUST).** When an adapter reinstates a captured context, it MUST first save the executing thread's prior context, install the captured context only for the work's duration, and restore the prior context afterward — including when the work throws — so a reused/pooled thread's own context is never clobbered. *Conformance: install context on a worker that already has one, run a task that throws, assert the worker's pre-existing context is intact after the task returns and after it throws.* +- **ASYNC-10 (MUST).** When an adapter propagates logging context, capture MUST occur at the point that identifies the logical caller — per-subscription for cold/reusable stream or promise objects, per-task-submission for executor decorators — not at object-construction time. A reused async object MUST pick up the live context of each use. *Conformance: assemble under context A, subscribe/execute under B → log lines carry B; re-subscribe under C → C.* +- **ASYNC-11 (MUST).** When an adapter propagates logging context, capture and restore MUST be safe when no logging-context backend is installed: an absent context captures as empty, and reinstating an empty context clears the target thread's context rather than raising. *Conformance: with no backend, run a full async round-trip through each adapter; assert no exception and empty context on the callback thread.* +- **ASYNC-12 (MUST).** On runtimes where a newly created worker does not inherit the spawning thread's logging context (lightweight threads or plain thread-local contexts), an adapter that propagates logging context MUST explicitly transfer it at the thread-creation boundary — distinct from any carrier-hop guarantee the runtime provides. *Conformance: on the lightweight-thread adapter, set a context entry on the caller thread and assert the transport call executing on the spawned worker sees it.* + +### 18.4 Error unwrapping and blocking bridge + +- **ASYNC-13 (MUST).** When surfacing a failure, adapters MUST unwrap the async framework's wrapper exceptions down to the original cause so typed handlers match the real exception. Unwrapping MUST terminate on the first non-wrapper cause, a null cause, or a detected cycle. *Conformance: complete a future with a wrapped cause and assert the failure signal is the original type; a self-referential wrapped cause terminates without infinite loop.* +- **ASYNC-14 (MUST).** An async→sync blocking bridge MUST honor thread interruption while awaiting: on interruption it MUST restore the interrupt flag, cancel the in-flight future, and throw an interrupted-I/O failure; and it MUST unwrap execution-wrapper exceptions so blocking callers see the original failure. A future cancelled independently MUST surface its cancellation as-is (not remapped to I/O). *Conformance: interrupt the calling thread mid-wait; assert an interrupted-I/O exception, the flag set, and the future cancelled; complete the future exceptionally with a wrapped I/O error and assert the unwrapped type is thrown.* + +### 18.5 Lifecycle + +- **ASYNC-15 (MUST).** An adapter that owns an executor or background threads MUST expose a close/dispose operation that is (a) idempotent — repeated calls safe, only the first performs shutdown/side-effects; (b) ownership-aware — releases only SDK-owned resources, never a caller-supplied executor/client; and (c) interrupt-safe — honors thread interruption on any blocking shutdown step. *Conformance: close twice → shut once, one side-effect; a BYO executor passed to a bridge is never shut down; interrupt the closing thread during a blocking shutdown and assert it returns promptly.* +- **ASYNC-16 (SHOULD).** An adapter that owns an executor SHOULD shut it down gracefully on close — stop accepting new work and wait for in-flight tasks rather than interrupting them — escalating to forceful shutdown only if the closing thread is itself interrupted. Callers needing eager abort use the interrupt/structured-cancellation path. *Conformance: start an in-flight request, call close, and assert it completes rather than being interrupted.* +- **ASYNC-17 (SHOULD).** The async transport SPI SHOULD provide a no-op default close so lightweight/functional implementations need not implement lifecycle management, while any implementation that owns resources overrides it to follow ASYNC-15. Behavior of executeAsync after close is undefined. *Conformance: a lambda transport with no close override constructs and its close is a safe no-op.* + +### 18.6 Delay, options, and streaming + +- **ASYNC-18 (MUST).** The non-blocking scheduled-delay primitive MUST complete after the requested delay without blocking a thread, MUST complete immediately for a zero delay, MUST reject a negative delay, and cancelling the returned future MUST cancel the underlying scheduled task so no scheduler thread is held. *Conformance: negative → rejected; zero → already-completed; positive → completes after the interval; cancelling the future cancels the scheduled task.* +- **ASYNC-19 (MUST).** Every bridge and facade overload that accepts per-call request options MUST thread those options into the wrapped send so per-request overrides survive the async boundary, rather than being dropped by the SPI's options-ignoring default overload. *Conformance: per adapter, pass options with a distinctive override and assert the wrapped send receives them.* +- **ASYNC-20 (MUST).** Once a Response has been delivered to the caller through the future, cancelling that future MUST NOT close the Response body; the caller owns closing it even when discarding it. (Distinct from ASYNC-5, which applies only to a value the future never delivered.) *Conformance: complete the future with a Response, hand it to a consumer, then cancel the future; assert the Response is not closed by the adapter.* +- **ASYNC-21 (MUST).** An adapter exposing a streaming source (SSE) as a reactive stream MUST honor downstream backpressure by polling the source at most once per unit of demand, MUST complete on end-of-source, MUST propagate a source exception as an error signal while not swallowing fatal errors, MUST NOT close the caller-owned source on any termination, and MUST treat the source as single-subscriber (a fresh source per subscription). *Conformance: subscribe with a `request(1)` discipline and assert one poll per request; drain and assert completion; make the source throw and assert an error signal; assert the caller-owned source is not closed after termination.* +- **ASYNC-22 (MUST).** Async transport implementations MUST be safe for concurrent calls from multiple threads, with all per-call mutable state confined to the returned future's completion graph. *Conformance: fire many concurrent executeAsync calls on one client and assert each future resolves to its own correct Response with no cross-talk.* + +--- + +## 19. Cross-Cutting Invariants and Policies + +These universal, subsystem-independent contracts every reimplementation must honor cut across transports, I/O, pipeline, auth, and instrumentation. A port that violates any is incorrect or unsafe even if each subsystem individually "works." + +A load-bearing subtlety a porter will otherwise get wrong: a protocol error carries a baked retryability flag AND the retry step gates protocol errors on a separate *configurable* status set. The configured set is what the retry step actually consults; the two are distinct notions with distinct default membership (XCUT-5 vs XCUT-7). + +### 19.1 Cancellation vs timeout + +- **XCUT-1 (MUST).** A thread/task cancellation MUST be surfaced as a distinct, terminal, NON-retryable signal, kept separate from a timeout. The portable intent: propagate cancellation without losing the ambient cancellation flag, and never let a cancelled operation be automatically retried. (Reference JVM pattern: catch the interrupt, re-assert the interrupt state, throw the I/O-family cancellation type.) *Conformance: interrupt a thread blocked in a send or retry wait; assert the surfaced error is the cancellation type, the interrupt/cancel flag is still set, and the retry layer does not re-issue.* +- **XCUT-2 (MUST).** A read/response/connect TIMEOUT MUST be classified as a RETRYABLE transport failure and MUST NOT set the cancellation flag. Timeout and cancellation MUST be told apart by the ambient cancellation state, not by matching a message string, even when the runtime represents both with the same exception type (and even when the timeout type is a *subtype* of the cancellation type — the timeout branch must be checked first). *Conformance: force a timeout with no interrupt pending → retryable type, flag clear, retry re-issues; then interrupt during the wait → the opposite classification.* +- **XCUT-3 (MUST).** Inter-attempt waits MUST be promptly cancellable: a pending wait MUST abort near-immediately on cancellation, surface the cancellation signal (not a spurious timeout), and cancel any timer/future it armed. The reference implements the wait as a scheduled timer completing an awaitable future (so a virtual-thread carrier can unmount and no shared pool thread is monopolized); a port SHOULD preserve that non-pinning property where its runtime has an equivalent concern, but the normative requirement is prompt cancellation, not the mechanism. *Conformance: start a retry with a long backoff, cancel, and assert the wait aborts near-immediately with the cancellation type and the timer is cancelled.* + +### 19.2 Error taxonomy and retry classification + +- **XCUT-4 (MUST).** The error taxonomy MUST have exactly two top-level branches: (a) protocol errors carrying a fully-received response, raised as an unchecked/runtime error; and (b) transport errors carrying no response, belonging to the runtime's I/O-error family so existing I/O catch sites keep matching. A transport error MUST report itself always-retryable at the error level. *Conformance: assert a 4xx/5xx maps to the response-carrying runtime error exposing status+headers+body; a connection failure maps to the I/O-family error with no response and isRetryable true, catchable as a generic I/O error.* +- **XCUT-5 (MUST).** The baked retryability flag of a protocol error MUST be computed once at construction from a single shared status classifier, never hardcoded per subclass. That classifier MUST treat 408, 429, and all 5xx except 501 and 505 as retryable, everything else not. This baked flag is a queryable property; the retry step's actual eligibility gate for a protocol error is the configured retryable-status set (XCUT-7). *Conformance: construct the error for each representative status and assert the baked flag matches the classifier; a 5xx outside the tested list (e.g. 507) bakes retryable true.* +- **XCUT-6 (MUST).** A transport-family or custom error type that declares itself retryable via the retryability capability MUST participate in retry decisions without editing the classifier — for such errors the classifier queries the capability, not a concrete-type match. Protocol errors are handled by XCUT-7 and their baked flag is not consulted by the retry step. *Conformance: introduce a new non-protocol error type declaring itself retryable and assert the retry layer retries it (subject to the safety gate) with no classifier edit.* +- **XCUT-7 (MUST).** For a protocol error, retry eligibility MUST be decided by a *configurable* retryable-status set, which is authoritative: the retry step consults this set (not the baked flag), and the set MAY widen or narrow the built-in classification. The default set is `{408, 429, 500, 502, 503, 504}`. The same set MUST also govern whether a freshly re-sent error-status response is re-classified as a failure for the next attempt. *Conformance: include 501 in the set → that error is retried; remove 500 → a 500 is not retried even though its baked flag is retryable.* +- **XCUT-8 (MUST).** The status-to-exception mapping factory MUST reject being asked to map a non-error status (1xx/2xx/3xx) — it MUST raise an argument error rather than fabricate a "successful exception." A convenience form MAY return an absent/null value for non-error statuses instead. *Conformance: the strict mapper throws for a 200 and a 302; the optional mapper returns absent/null.* +- **XCUT-9 (MUST).** Any classification walking an error's cause chain MUST be cycle-safe: track visited causes by reference identity and terminate on a self-referential or cyclic chain. *Conformance: build an error whose cause points back to itself and run the classifier; assert it returns without hanging.* + +### 19.3 Retry-safety + +- **XCUT-10 (MUST).** Retry-*safety* MUST be decided at the retry step, independently of retryability, and applied uniformly to both protocol and transport failures: (a) a request without a body is retry-safe only if its method is idempotent — a bare POST MUST NOT be retried even when the failure is a transport error that never reached the server; (b) a request with a body is retry-safe only if that body is replayable — a single-use/streaming body MUST NOT be re-sent. The safety gate MUST NOT special-case transport errors. (Ensuring a re-sent body-bearing request on a non-idempotent method is safe — e.g. via an idempotency key — is the caller's responsibility.) *Conformance: body-less GET → retried; body-less POST failing with a protocol error → not retried; body-less POST failing with a transport error → still not retried; POST with replayable body → retried; POST with streaming body → not retried.* + +### 19.4 Concurrency and lifecycle + +- **XCUT-11 (MUST).** Components documented as shared/reusable across concurrent requests (pipeline steps, auth handlers, redactors, factories) MUST be safe for concurrent invocation. Per-call mutable state (attempt counters, deadlines, seen-URI sets) MUST live on the call's stack/local state, not the shared instance, and any shared mutable state MUST be synchronized. *Conformance: invoke one shared step instance from many threads with distinct requests and assert no cross-talk.* +- **XCUT-12 (SHOULD).** Hot-path reads of a credential/token cache SHOULD be wait-free (e.g. a volatile-published read taking no lock while valid). Refresh SHOULD be single-flight — only one concurrent caller fetches an expiring token while the others reuse the result. Any lock guarding the refresh MUST be scoped to that cache (per-credential/per-step) so it never serializes unrelated in-flight requests or the global scheduler; holding that scoped lock across the (possibly blocking) fetch to enforce single-flight is acceptable and intended. The primitive is non-normative. *Conformance: race N threads on an expiring token; assert exactly one fetch, cached reads take no lock, and requests through a different credential cache are not serialized.* +- **XCUT-13 (MUST).** `close()`/`shutdown()` MUST be idempotent (latched so repeats are no-ops) and MUST NOT block on interrupt-sensitive waits — it uses non-blocking shutdown and preserves the ambient interrupt/cancel flag as-is. *Conformance: call close twice → the second is a no-op; call close with the interrupt flag set → returns without clearing it and without blocking.* +- **XCUT-22 (MUST).** The SDK MUST close only resources it created. A caller-supplied (BYO) transport client, executor, or connection pool MUST NOT be closed by the SDK; the caller owns its lifecycle and may keep using it after the SDK component is closed. *Conformance: build a transport around a caller-supplied client, close the transport, then reuse the client → it still works.* +- **XCUT-23 (MUST).** A pluggable single-implementation seam (the I/O provider, and any similar SPI) MUST resolve deterministically: an explicit install always wins; otherwise the implementation is auto-discovered from the environment/classpath; and zero or multiple candidates with no explicit selection MUST fail loudly with an actionable error rather than silently pick one or no-op. Reads of the resolved provider MUST be concurrency-safe. *Conformance: no provider → resolution throws; two present and no explicit install → throws; an explicit install → wins even against an auto-discoverable one.* + +### 19.5 Bounded memory + +- **XCUT-14 (MUST).** Every process/instance-lived map whose key space is influenced by callers or servers (context registries, per-nonce counters, similar caches) MUST be bounded by a hard cap and MUST drain back under the cap after each insert using a loop (not a single pre-insert check-then-evict), so a concurrent insert burst converges to the bound. Arbitrary-victim eviction is acceptable; the cap is a memory backstop, not the primary cleanup mechanism. *Rationale: unbounded caller/server-keyed maps are a memory-exhaustion/DoS vector.* *Conformance: insert far more than the cap of distinct keys (single- and multi-threaded); assert map size never exceeds the cap and a burst does not leave it stuck above.* + +### 19.6 Immutable models + +- **XCUT-15 (MUST).** Public wire models (request, response, headers, media type, status, etc.) MUST be immutable after construction and safe to share across threads. Mutation MUST be expressed as producing a new instance. A model MUST NOT retain an alias to externally-mutable state that a post-construction mutation could use to alter it — either defensively copy an ingested mutable collection or hold an immutable collection type. *Conformance: mutate a collection passed into a builder after build; assert the built model is unchanged; confirm every "setter" returns a new instance.* + +### 19.7 Security by default + +- **XCUT-16 (MUST).** A credential MUST NOT be stamped over a non-secure (non-HTTPS) transport. The auth layer MUST reject (fail loudly) before any token fetch or header write when about to attach a credential and the scheme is not https (case-insensitive). The guard applies only on the credential-attaching path — a deliberately credential-free re-issue (e.g. a marker-suppressed cross-origin redirect) MAY proceed over any scheme. *Conformance: send an http:// request through a credential-stamping auth step → fails before any credential is attached; a marker-suppressed cross-origin re-issue over http:// proceeds credential-free.* +- **XCUT-17 (MUST).** Redirect handling MUST enforce credential hygiene: (a) strip Authorization before every redirect re-issue (even same-origin); (b) on a cross-origin redirect (judged against the original seed origin, not the previous hop) additionally strip origin-scoped credentials (Cookie, Proxy-Authorization) and ensure the caller's credential is not re-applied to the foreign host; (c) drop any userinfo in the Location before re-issue; (d) reject an HTTPS-to-HTTP scheme downgrade by default, permitting it only via explicit opt-in (and logging the deviation). *Conformance: same-origin → Authorization absent, credential re-stamped by auth; cross-origin → Authorization/Cookie/Proxy-Authorization absent and no credential re-stamped; Location with userinfo → userinfo dropped; HTTPS-to-HTTP → fails unless downgrade enabled.* +- **XCUT-18 (MUST).** Header names and outbound header values MUST be validated at the transport-agnostic model layer before reaching any transport. Names MUST reject all C0 control bytes (0x00-0x1F, including CR, LF, NUL, and HTAB) and DEL (0x7F). Outbound values MUST reject the same set *except* horizontal tab (0x09). Both names and outbound values MUST reject non-ASCII bytes (≥ 0x80). Inbound response values MAY be validated leniently (obs-text permitted) but MUST still reject control bytes except HTAB. *Rationale: a bare CR/LF is the request/header-splitting vector.* *Conformance: set a header name or outbound value with CR/LF/NUL/non-ASCII → rejected; an outbound value with HTAB → accepted, but a name with HTAB → rejected; an inbound obs-text value → accepted, a CR/LF-bearing inbound value → rejected.* +- **XCUT-19 (MUST).** Logging/telemetry MUST redact secrets by default: (a) URL userinfo always redacted (never allow-listed); (b) URL query-parameter values and key=value fragment tokens redacted unless the parameter name (case-insensitive) is allow-listed; (c) header logging default-deny — only an explicit allow-list of non-credential headers emitted verbatim; (d) credential objects MUST NOT reveal their secret in string/serialized form; (e) full request/response body logging OFF by default. *Conformance: log a URL with userinfo, a secret query param, and a token fragment → userinfo and non-allow-listed values redacted; log an Authorization header → not emitted verbatim; serialize a credential → secret absent; default log level emits no bodies.* +- **XCUT-20 (MUST).** Observability code paths (redaction, event emission, span/metric recording) MUST NEVER throw into the caller's request path. A failure to redact/render/emit MUST degrade gracefully (a safe placeholder such as a malformed-URL marker, or a self-describing instrumentation-error event) and let the request proceed. *Conformance: feed the redactor a malformed URL and the instrumentation step a failing sink; assert the request still completes and no exception escapes.* +- **XCUT-21 (MUST).** Any security-relevant random value (auth client nonces / cnonce and similar unpredictability-dependent tokens) MUST be drawn from a cryptographically-strong PRNG with sufficient entropy (the reference uses ≥ 128 bits for the Digest cnonce), never a non-cryptographic RNG. *Conformance: assert the nonce source is a CSPRNG and each nonce is ≥ 128 bits; statistically assert non-repetition.* + +### 19.8 Diagnostic previews + +- **XCUT-24 (SHOULD).** Diagnostic/preview reads of caller- or server-controlled payloads (error-body snapshots, request/response body log previews) MUST be byte-capped and SHOULD be non-consuming — a preview MUST NOT materialize an unbounded payload into memory and MUST NOT disturb the primary read path the consumer will use. *Conformance: take a body snapshot/preview of a 10 MB response with a small cap; assert at most cap bytes are buffered, the surfaced error/log carries the truncated preview, and a subsequent consumer read still sees the full body.* + +--- + +## 20. Non-Functional Requirements and Quality Bar + +These language-neutral non-functional invariants are the cross-cutting engineering guarantees the reference implementation enforces mechanically through its build. A faithful port ships the same guarantees, enforced by its own ecosystem's equivalent tooling, so consumers get the same footprint, stability, and safety promises regardless of language. Specific numbers and tool names (80% coverage, the shrinker, the exact static analyzers) are the JVM instantiation; the requirement is that the *equivalent gate exists and blocks*. + +### 20.1 Modularity and dependency inversion + +- **NFR-1 (MUST).** The core module MUST depend only on the language standard library, the runtime, and a compile-time-only logging facade. It MUST NOT carry a runtime dependency on any concrete HTTP transport, serialization library, I/O implementation, or async framework. Concrete capabilities are supplied by separate adapter units that depend on the core, never the reverse. *Conformance: the core artifact's published dependency metadata lists zero runtime dependencies beyond the stdlib; the logging facade appears compile-scope only.* +- **NFR-2 (SHOULD).** Each optional capability (transport, serialization format, I/O backend, async bridge) SHOULD be a separately installable unit depending on the core plus at most one third-party library, so a consumer composes only the units it uses. *Conformance: for each adapter, assert its dependency metadata lists the core plus at most one external library, and the core is not required to pull in any adapter.* + +### 20.2 Public API surface and compatibility + +- **NFR-3 (SHOULD).** The public API surface SHOULD be explicit and minimal: every exported declaration is deliberately public with a declared type, implementation details kept non-exported, and each adapter keeps its public surface as small as its capability allows (a single entry point where possible; a small cohesive cluster where genuinely needed). Nothing internal leaks by default. *Conformance: enumerate exported symbols per unit; no symbol documented as internal appears; a public declaration lacking explicit type/visibility is a build error where the language enforces it.* +- **NFR-4 (SHOULD).** The public API of every published unit SHOULD be captured in a checked-in, machine-comparable snapshot, and the build SHOULD fail on any drift. An intentional API change is landed by regenerating and committing the snapshot in the same change; the regeneration tool MUST NOT be used to silence an unintentional break. Unpublished/sample/test-only units are excluded. *Conformance: change a public signature without updating the snapshot → gate fails; regenerate → gate passes with a reviewable diff.* + +### 20.3 Enforced quality gates + +- **NFR-5 (SHOULD).** The build SHOULD enforce a minimum aggregate line-coverage floor (currently 80%) across the library units, wired into the default build lifecycle. Sample/example code, test-only guards, and test fixtures are excluded from the aggregate. *Conformance: delete tests so aggregate line coverage drops below the floor → default build fails.* +- **NFR-6 (SHOULD).** Compiler warnings SHOULD be treated as errors across every unit, including deprecations. *Conformance: introduce a deprecation (or any) warning → the build fails until resolved.* +- **NFR-7 (SHOULD).** The build SHOULD run automated style/lint and static-analysis checks with findings treated as fatal (a nonzero issue budget fails the build). Where an analyzer cannot run on a given unit's toolchain, disabling it SHOULD be a narrowly-scoped, documented exception with explicit re-enable conditions, not a silent global relaxation. *Conformance: introduce a style/static-analysis violation → build fails; for any disabled analyzer, confirm a documented reason and re-enable condition.* +- **NFR-17 (MUST).** The quality gates backing the requirements above (compatibility snapshot, coverage floor, warnings-as-errors, lint/static-analysis, shrink-survival where applicable, and runtime-floor checks) MUST be enforced automatically and be blocking — failing the standard build/CI rather than being advisory. *Rationale: a quality bar only holds if mechanically enforced.* *Conformance: for each gate, introduce a violation and confirm the ordinary build fails; confirm no gate is report-only.* + +### 20.4 Dead-code elimination survival + +- **NFR-8 (MUST).** In target ecosystems that support whole-program dead-code elimination / tree-shaking / minification, the SDK MUST ship the keep/retain configuration a downstream shrinker needs so its reflectively-reached and runtime-wired surface survives shrinking. That configuration MUST cover the runtime-wired SPI seams (I/O provider, transport clients, serde) and the immutable models and reflectively-bound types (request/response models, the Tristate type, and the reflective metadata serializers read). In ecosystems without such a build step this requirement does not apply. *Conformance: shrink a program depending only on the shipped keep-configuration, then run it against a live round-trip → succeeds; remove any shipped keep-rule → the run fails.* +- **NFR-9 (SHOULD).** The shipped shrinker keep-configuration SHOULD be guarded by an automated regression check, wired into the default build, that shrinks a real consumer using only the shipped rules and runs it end-to-end against a live round-trip, failing the build if any runtime-wired or reflectively-reached surface is stripped. The guard SHOULD also assert every shipped rule file is present. *Conformance: drop a shipped keep-rule or rename a runtime-wired type → the guard fails the ordinary build.* + +### 20.5 Runtime floor discipline + +- **NFR-10 (MUST).** The SDK MUST declare a lowest-supported-runtime floor and target it for all general-purpose units. A capability that genuinely requires a newer runtime MUST be isolated into its own unit that declares the higher floor explicitly; that unit MUST NOT be a hard dependency of the general-purpose core. No produced artifact may reference runtime/stdlib APIs absent on the floor it declares — the emitted-artifact target and the visible-API level must agree, not just the compiler toolchain. *Rationale: compiling against a newer stdlib while emitting artifacts declared for an older runtime links on the build machine but fails at call time.* *Conformance: run each unit's artifact on its declared minimum runtime and exercise it (no missing-symbol failures); statically scan each artifact for references to symbols newer than its floor (none); confirm higher-floor units are optional.* + +### 20.6 Concurrency-model agnosticism + +- **NFR-11 (SHOULD).** The core SHOULD be concurrency-model agnostic: it exposes plain blocking operations correct on any scheduler and leaks no async-framework types (coroutines, reactive publishers) into the core public surface. Shared mutable state is guarded for safe concurrent access, and the synchronization primitives SHOULD avoid pinning or blocking lightweight scheduler threads where the target runtime distinguishes them. *Conformance: confirm no async-framework type in the core public surface; drive the same core call concurrently from multiple scheduler kinds and assert race-free results.* + +### 20.7 Artifact hygiene + +- **NFR-12 (SHOULD).** Build artifacts SHOULD be reproducible: identical source inputs SHOULD yield byte-for-byte identical output artifacts (normalized/stripped embedded timestamps, deterministic entry ordering). *Conformance: build the same commit twice in clean environments and compare artifact digests; they must be identical.* +- **NFR-13 (SHOULD).** Every source file SHOULD carry the project's license/SPDX header block. In the reference this is a review convention, not a mechanical gate. *Conformance: scan all source files for the required header.* +- **NFR-14 (SHOULD).** Dependency versions, plugin/tool versions, and project coordinates SHOULD live in a single source of truth rather than being restated per unit, so a bump is ideally a one-line edit applying uniformly. A port SHOULD avoid restating coordinates in per-unit build config. *Conformance: grep for version/coordinate literals outside the central catalog; bump a dependency version once and confirm it propagates everywhere.* +- **NFR-15 (SHOULD).** Published artifacts SHOULD embed self-identifying version metadata the SDK can resolve at runtime, so runtime-emitted identifiers (e.g. a User-Agent) report the real version rather than an "unknown" placeholder. *Conformance: query the SDK's self-reported version at runtime from a packaged artifact; it must equal the build version and never the placeholder.* +- **NFR-16 (SHOULD).** Published artifacts SHOULD be cryptographically signed for provenance, with signing enforced on the release/CI path and made gracefully optional in local builds lacking signing keys. *Conformance: a CI/release build fails an unsigned publication; a local build without keys still publishes unsigned.* + +--- + +## Appendix A — Glossary + +**Adapter unit (pay-for-what-you-use module).** A separately installable unit supplying one concrete capability by depending on the core plus at most one third-party library, keeping its public surface minimal. Consumers compose only the units they need. + +**Aggregate coverage floor.** A minimum line-coverage percentage computed across all library units combined (not per-unit), excluding samples and test-support code, enforced by the default build. + +**Auth challenge.** A parsed RFC 7235 WWW-Authenticate / Proxy-Authenticate directive (a scheme plus a parameter map) a server returns on a 401/407 to indicate how a client may authenticate. + +**Backpressure.** Flow control in which a consumer's demand governs how fast a producer is polled; in this SDK, the blocking source read is the backpressure mechanism for SSE (SSE-39). + +**BYO (bring-your-own) resource.** A dependency (native HTTP client, executor, connection pool) the caller constructs and hands to the SDK; the caller owns its lifecycle and the SDK never closes it, in contrast to an SDK-managed resource the SDK created and must release on close. + +**Canonical completion future.** The single dependency-free async value type that carries exactly one success value or one failure and is the interop pivot every ecosystem adapter bridges to and from (JVM reference: CompletableFuture). + +**Cold publisher / per-subscription capture.** A reusable async object that (re)issues its request and (re)captures logging context on each subscription rather than once at assembly time. + +**Copy-on-write derive.** Producing a reconfigured configuration from an existing one by applying a mutator to a prefilled builder while leaving the receiver unchanged (the override map is copied up front; pure read seams are shared by reference). + +**Cross-origin redirect marker.** An internal, transport-invisible sentinel the redirect step sets on a cross-origin re-issue so the auth step suppresses credential stamping onto a server-chosen foreign host; stripped before the wire and unforgeable. + +**Cursor / continuation token.** An opaque string a server returns to identify the next page of a paginated result; the pagination cursor strategy folds it into the next request's query. + +**Deep value equality.** Content-based equals/hashCode comparison that recurses into arrays element-by-element while falling back to ordinary equality for non-arrays, with equals and hashCode kept mutually consistent (including NaN-equals-NaN and +0.0-unequal-to-(-0.0) array semantics). + +**Diagnostic-context (MDC) allow-list.** The set of thread-local logging-context keys folded into an SDK log event (default `{trace.id, span.id}`), preventing arbitrary application context from leaking into SDK-owned events. + +**Dispatch (SSE).** The framing act, triggered by a blank line, of collapsing the fields accumulated since the previous boundary into one Server-Sent Event. + +**Drain-to-cap bounded map.** A concurrent map whose caller/server-influenced keys are capped: after each insert it is drained in a loop back under a hard bound, converging even under concurrent insert bursts; eviction victim is arbitrary. + +**Idempotent method.** An HTTP method whose repetition has the same effect as a single invocation; the SDK's idempotent set is `{GET, HEAD, OPTIONS, PUT, DELETE}`, used as the retry-safety gate for body-less requests. + +**Live tail.** On the SSE / body-preview exceeds-cap path, the still-open delegate source retained after the prefix was captured, carrying the un-buffered remainder and readable exactly once. + +**Origin tuple (RFC 6454).** The (scheme, host, effective-port) triple; two URLs share an origin iff all three match, with case-insensitive host and scheme-default port. Cross-origin is judged against the original seed request, not the previous hop. + +**Ownership-aware lifecycle.** The close/dispose discipline where the SDK releases only resources it created and never a caller-supplied one; close is idempotent. + +**Page.** One page of results wrapping the live transport response; its materialized items and derived metadata survive close, while the raw body/connection is valid only until close. + +**PageInfo.** A pagination strategy's parse output: the items on this page plus the next-page request, where a null/absent next-request is the single end-of-stream signal. + +**Pagination strategy.** A stateless, immutable parser that, given a response and the original request template, returns a PageInfo; three built-ins are cursor, page-number, and Link-header. + +**Pooled-thread poisoning.** The failure mode where an interrupt aimed at a cancelled call reaches a worker after it has returned to its pool and picked up unrelated work; prevented by an ordering handshake. + +**Protocol error.** An error meaning a complete response was received but its status is 4xx/5xx; an unchecked/runtime error carrying the response. + +**Provider / seam.** A narrow abstraction (SPI) the core depends on but never implements, behind which a concrete capability (I/O, transport, serde) plugs in. + +**Quality gate.** An automated, build-blocking check that fails the standard build when its condition is not met (coverage floor, API-snapshot drift, warnings, lint/static-analysis, shrink-survival, runtime-floor). + +**Redaction policy.** Centralized scrubbing of secrets from anything logged: URL userinfo always removed, query/fragment values removed unless allow-listed, header values gated by an allow-list, credential objects never revealing their secret. + +**Replayable body.** A request body whose write can be invoked more than once producing identical bytes; non-replayable (single-use, stream-backed) bodies trip a consume-once guard on a second write. + +**Retryability.** Whether a failure condition is transient — for a protocol error decided by the configured retryable-status set at the retry step; for a transport error, always transient. + +**Retry-safety.** Whether it is safe to replay a specific request, decided at the retry step from HTTP-method idempotency (body-less) or body replayability (body-bearing); orthogonal to retryability. + +**Serde.** A bundle exposing one serializer, one deserializer, and the declared wire media type for one format; the SDK's format-agnostic serialization seam. + +**Shrink-survival keep-configuration.** Retain/keep rules the SDK ships so a downstream whole-program shrinker does not eliminate reflectively-reached or runtime-wired surface. + +**Transport error.** A failure that produced no response (connect refused, DNS/TLS failure, read timeout, peer reset); belongs to the runtime's I/O-error family and is always-retryable at the error level. + +**Tristate.** A three-valued sum type — Absent / Null / Present(value) — distinguishing a missing key from an explicit null from a present value at the serialization boundary, primarily for HTTP PATCH. + +**TypeRef / type witness.** An explicit runtime carrier of a target type (a raw class token or a full generic capture) passed into deserialization so a language with type erasure recovers the intended type. + +**W3C Trace Context.** The interoperable trace-correlation format the instrumentation context complies with: trace id, span id, trace flags, and trace state, with reserved all-zero invalid sentinels. + +--- + +## Appendix B — Conformance Test Checklist + +A porter runs this checklist to prove a reimplementation conforms. Each item references the requirement IDs it exercises. Items are organized by subsystem; a check passes only when the observable behavior matches. + +### B.1 Pagination (PAGE) + +- [ ] Item view and page view over one 3-page walk yield concatenated server-order items and three page objects (PAGE-1); page metadata survives close (PAGE-2); a page closes its response exactly once and a fetcher does not close it (PAGE-3). +- [ ] Blocking iteration triggers zero exchanges until the first probe, then one per page consumed; async begins fetching on invocation (PAGE-6); no fetch past the terminal page and idempotent end-probes (PAGE-7); two iterations each drive a full sequence (PAGE-8). +- [ ] `cap ≤ 0` throws at construction; a non-advancing server stops at exactly N exchanges (PAGE-9); the default cap is effectively unbounded (PAGE-10). +- [ ] Item view eager-closes each page before yielding items on partial consume (PAGE-11); page view releases held + buffered pages on early break / probe-then-close (PAGE-12); a second page-view iterator throws (PAGE-14); a held-page close error surfaces wrapped, with a second failure suppressed (PAGE-15). +- [ ] Parse-throw closes the response inline with the parse error primary and a close error suppressed (PAGE-13). +- [ ] Cursor null/empty → end, single body read (PAGE-16); page-number empty items → end, start-page fallback on garbage (PAGE-17); Link-header rel=next with quoted commas, multi-token rel, multi-header split (PAGE-18, PAGE-20); query-only reference preserves the base path, unparseable target → end (PAGE-19). +- [ ] Verbatim query splice preserves untouched params byte-for-byte (PAGE-21); RFC 3986 component encode/decode with `+` as data (PAGE-22); set replaces first/appends/removes, whole-URL follow preserves method/headers/body (PAGE-23); non-query URL components preserved (PAGE-24). +- [ ] Async: no per-page thread block, cancel aborts the in-flight transport future (PAGE-25); page-granular cancellation drops+closes a staged page (PAGE-26); every response closed exactly once across all paths (PAGE-27); failures surface the original cause, eager transport throw handled (PAGE-28); serial ordered delivery + executor mode (PAGE-29); executor rejection fails the walk and closes the staged page (PAGE-30); trampoline over thousands of sync pages (PAGE-31); throwing close on success reported through the future (PAGE-32); documented cancellation-race ownership (PAGE-33). +- [ ] Fetcher front-end: first fetcher once, nextLink over token, blank link / null terminates (PAGE-34); same options instance threaded and mutation observed (PAGE-35). +- [ ] Per-call options reach every page exchange (PAGE-36). + +### B.2 Server-Sent Events (SSE) + +- [ ] Blank-line dispatch and fresh per-block accumulators (SSE-1); LF/CR/CRLF terminators (SSE-2); first-colon field split, colon-less/trailing-colon empty value (SSE-3); present-but-empty distinct from absent (SSE-4); single-space strip (SSE-5); comment capture counts as content (SSE-6); unknown fields discarded (SSE-7); multi-`data` accumulation as a list (SSE-8); NUL-bearing id ignored (SSE-9); absent `event` not defaulted to `message` (SSE-10); digit-only `retry` with overflow rejection (SSE-11); start-only BOM strip, mid-stream BOM preserved (SSE-12). +- [ ] Permissive dispatch of id-/retry-/comment-only blocks, no-field blocks skipped (SSE-13); EOF partial dispatch (SSE-14); stable end sentinel (SSE-15); no persistent last-event-id (SSE-16); reader does not own the source (SSE-17); single-thread reader contract (SSE-18); unbounded lines accepted or a documented cap (SSE-19). +- [ ] Immutable event with defensively-copied data (SSE-20); value equality over five fields (SSE-21); is-empty predicate with comment-non-empty (SSE-22). +- [ ] Facade closes the owned resource exactly once across clean end / explicit close / use-block / partial consume / mid-stream failure (SSE-23, SSE-24, SSE-25); single-pass iterator (SSE-26); post-close iterator fails, in-flight ends cleanly (SSE-27); idempotent close (SSE-28); mid-stream failure releases before propagating with a suppressed close error (SSE-29); auto-terminal release failure swallowed vs explicit close propagates (SSE-30); cross-thread close (SSE-31); response-opening convenience binds lifecycle and rejects a bodyless response (SSE-32). +- [ ] Typed adapter: mapper receives (event-name, `\n`-joined data) (SSE-33); Value/Skip/Done honored (SSE-34); lazy per-element decode (SSE-35); mapper throw releases the resource first (SSE-36). +- [ ] Core holds no sentinel/error/serde convention (SSE-37); no auto-reconnect / last-event-id header (SSE-38); pull-based, one poll per demand (SSE-39); lazy single-pass convenience views reuse one reader (SSE-40); reactive fatal/non-fatal split and source-ownership documented (SSE-41). + +### B.3 Serialization (SERDE) + +- [ ] Serde bundle round-trips through its own serializer/deserializer (SERDE-1); declared media type is the default Content-Type, not defaulted at the SPI (SERDE-2). +- [ ] Streaming/buffer targets not closed (SERDE-3); encode-into-buffer returns length, honors offset, throws a non-serde range error on overflow (SERDE-4). +- [ ] Decode requires an explicit type witness (SERDE-5); parametric carriers preserve element types, a no-codec decoder fails loudly on a parametric ref but decodes a plain-class carrier (SERDE-6); reified helper routes through the carrier (SERDE-7); carrier rejects an unresolved type variable at construction (SERDE-8). +- [ ] Failures surface the stable serde type chaining the original cause, no library type escapes (SERDE-9); write/read directional subtypes (SERDE-10); unchecked failures (SERDE-11); genuine stream I/O error propagates unwrapped (SERDE-12); wire null into a non-null target fails naming the type across overloads (SERDE-13). +- [ ] Tristate: three states with Present(null) unrepresentable (SERDE-14); Absent omits key / Null emits null / Present emits value (SERDE-15); round-trip decode of `{}` / `{"x":null}` / `{"x":v}` (SERDE-16); missing key → Absent via field default (SERDE-17); construction/consumption helpers, ofNullable never yields Absent (SERDE-18); default codec auto-registers the wiring (SERDE-19); top-level/array-element degradation (SERDE-20); stable sentinel strings (SERDE-30). +- [ ] Strict cross-shape coercion rejected (SERDE-21); widening and empty-string→string permitted (SERDE-22); unknown fields ignored (SERDE-23); ISO-8601 dates round-trip (SERDE-24); fresh codec per factory call (SERDE-25); caller-supplied codec not mutated (SERDE-26); shared-after-config thread safety (SERDE-29). +- [ ] Streaming response handler closes on all paths, missing body / codec / I/O handling (SERDE-27); status-aware handler decodes only 2xx, buffers a bounded 4xx/5xx error body, raises a status-naming serde exception for other non-2xx (SERDE-28). + +### B.4 Instrumentation (OBS) + +- [ ] Disabled level allocates nothing and emits nothing, returning a shared inert event (OBS-1); four levels map to the backend (OBS-2); empty key rejected, null value → literal `null` (OBS-3); reserved `event` tag emitted exactly once, empty clears (OBS-4); field > global > diagnostic-context precedence, one occurrence per key (OBS-5); total rendering with placeholder on a throwing string conversion (OBS-6); bounded truncation with primitives exempt (OBS-7); single-emit guard correct under races (OBS-8); global context on every event (OBS-9); reserved-key collision warned once per logger (OBS-40). +- [ ] Diagnostic-context allow-list defaults to `{trace.id, span.id}`, null folds all, null values skipped (OBS-10). +- [ ] URL userinfo always redacted (OBS-11); query values redacted unless allow-listed, atomic multi-value (OBS-12); fragment key=value scrubbed, plain fragment preserved (OBS-13); scheme/host/port/path preserved, no spurious `?` from a fragment `?` (OBS-14); malformed URL → `[malformed url]`, never throws (OBS-15); header-value URLs redacted with path-kept `?***` fallback (OBS-16); Location/Content-Location redacted via the shared policy (OBS-17); header-name allow-list default-deny (OBS-18); dropped-header verbosity policy (OBS-19). +- [ ] Logging failures caught and re-emitted as `http.instrumentation.*`; tracer/meter throws propagate (OBS-20). +- [ ] Span recording flag and idempotent end (OBS-21); scope restores prior span on close incl. on throw (OBS-22); log-correlation scope pushes/restores trace.id/span.id, skipped for non-recording (OBS-23); async MDC bridge saves/installs/restores incl. on throw (OBS-24); allocation-free no-op tracing defaults (OBS-25). +- [ ] W3C identifiers and all-zero invalid sentinels (OBS-26); W3C / Datadog / no-op id generation never all-zero (OBS-27); HTTP-tracer vocabulary with default no-ops (OBS-28); lifecycle ordering incl. exhausted→failed pairing (OBS-29); tracer/meter callbacks concurrent-safe and never throw (OBS-30). +- [ ] Meter yields a monotonic counter + histogram with attributes, no-op default discards, no metrics runtime pulled (OBS-31); OpenTelemetry-convention names/units (OBS-32); counter non-negative-only, histogram tolerates any input (OBS-33). +- [ ] Log level none/headers/body with span+metrics running independent of level (OBS-34); layered tolerant level resolution, no baked-in key (OBS-35); bounded body preview streams full body to caller (OBS-36); async skips capture for unknown-length bodies (OBS-37); charset-aware/binary-safe non-throwing preview (OBS-38); stable event names/keys with redacted url.full (OBS-39). + +### B.5 Configuration (CFG) + +- [ ] Four-layer precedence override > env > normalized-property > default (CFG-1); empty env treated as absent (CFG-2); property key normalization (CFG-3); raw property accessor without normalization (CFG-4); typed accessors resolve through the full layered lookup (CFG-38). +- [ ] Never-throw int/duration accessors, negatives valid (CFG-5); strict boolean only true/false (CFG-6); duration ISO / shorthand / bare-number-milliseconds, negatives rejected (CFG-7). +- [ ] Built config immutable, override map copied at build (CFG-8); copy-on-write derive with shared source seams (CFG-9); remove drops only the override layer (CFG-10); substitutable env/property seams (CFG-11); single-threaded builder (CFG-12); global slot last-write-wins (CFG-13); well-known key constants (CFG-14); null required argument rejected (CFG-37). +- [ ] Clock seam (now/monotonic/interruptible sleep) with a shared default (CFG-15); monotonic non-decreasing, wall-clock not for elapsed (CFG-16); sleep rejects negative, honors cancellation re-asserting the flag (CFG-17); non-blocking scheduled delay cancels its task (CFG-18); async-wrapper unwrap cycle-safe (CFG-19); interruptible task future with clean interrupt state (CFG-20); orphaned closeable result closed on discard (CFG-21). +- [ ] Immutable credential-masking proxy model (CFG-22); glob bypass full-string case-insensitive (CFG-23); proxy resolution precedence never throws, host/port from the same layer, https-only creds (CFG-24); explicit in-range port or null (CFG-25); non-proxy list precedence and escape handling (CFG-26); single bare `*` → bypass-all (CFG-27); opt-in environment resolution (CFG-28). +- [ ] RFC 1123 canonical formatting (CFG-29) and tolerant parsing with informational weekday and zone aliases (CFG-30), strict on the rest (CFG-31); non-blocking non-cryptographic type-4 UUID (CFG-32); deep value equality content-based and null-safe (CFG-33) with NaN/signed-zero and kind-distinct array semantics (CFG-34); shared retryability classifier where implemented (CFG-35); build/runtime descriptor with non-blank `unknown` fallback (CFG-36). + +### B.6 Transport (TRANSPORT) + +- [ ] Native redirects and native auto-retry disabled on SDK-managed transports (TRANSPORT-1, TRANSPORT-2). +- [ ] Sync cancellation → terminal interrupt type with the flag preserved, not the retryable type (TRANSPORT-3); timeout → retryable type with a clear flag, timeout-subtype checked first (TRANSPORT-4); per-call timeout applies to one call only (TRANSPORT-5); sub-resolution positive timeout clamped not truncated (TRANSPORT-6); async future cancel propagates into the native exchange (TRANSPORT-7); native-internal cancel → terminal type, timeout stays retryable (TRANSPORT-8); adaptation-race response closed (TRANSPORT-9). +- [ ] Explicit Content-Type wins, body-derived only when absent (TRANSPORT-10); framing headers dropped and recomputed (TRANSPORT-11); model-valid-but-native-rejected header dropped not thrown, sync and async (TRANSPORT-12); dropped-header logging policy bounded and case-insensitive (TRANSPORT-13); malformed inbound header dropped not response, obs-text preserved (TRANSPORT-14). +- [ ] Ownership-aware close leaves BYO client usable (TRANSPORT-15); idempotent, non-blocking, interrupt-safe close (TRANSPORT-16); single-use body written once (TRANSPORT-17); re-subscribable producer replays identical bytes or fails on buffering failure (TRANSPORT-18); abandoned streaming subscription unblocks its producer (TRANSPORT-19). +- [ ] No-response failure → retryable I/O-subtype exception (TRANSPORT-20); pre-dispatch async failure via the future not synchronous throw (TRANSPORT-21); response-adaptation throw closes the native response (TRANSPORT-22); async never completes with null on success (TRANSPORT-23); vendor status codes surfaced with a readable body (TRANSPORT-24); lazy streaming body with close-cascade (TRANSPORT-25); body-less request valid for any method, zero-length body substituted where required (TRANSPORT-26); malformed inbound Content-Type/Length downgraded (TRANSPORT-27); file body zero-copy where supported and replayable (TRANSPORT-28); concurrent-safe immutable transport (TRANSPORT-29); unsupported proxy features discoverable, credentials never leaked (TRANSPORT-30). + +### B.7 Asynchronous runtime adapter (ASYNC) + +- [ ] Single-value future non-null on success or exceptional (ASYNC-1); construction failures via the failure channel (ASYNC-2). +- [ ] Two-mode cancellation with queued/finished tasks never interrupted (ASYNC-3); ordered interrupt delivery prevents pooled-thread poisoning under stress (ASYNC-4); orphaned closeable closed exactly once on the lost race (ASYNC-5); bidirectional cancellation per adapter (ASYNC-6); documented per-adapter interrupt-mode-vs-not (ASYNC-7). +- [ ] Logging-context propagation across hops (ASYNC-8); save/install/restore incl. on throw (ASYNC-9); per-subscription / per-submission capture (ASYNC-10); safe with no logging backend (ASYNC-11); explicit transfer at the thread-creation boundary on lightweight-thread runtimes (ASYNC-12). +- [ ] Wrapper-exception unwrapping cycle-safe (ASYNC-13); blocking bridge honors interruption and unwraps (ASYNC-14). +- [ ] Owned-executor close idempotent/ownership-aware/interrupt-safe (ASYNC-15); graceful executor shutdown on close (ASYNC-16); no-op default close for functional implementations (ASYNC-17). +- [ ] Non-blocking scheduled delay, zero immediate, negative rejected, cancel cancels the task (ASYNC-18); per-call options threaded through every bridge (ASYNC-19); delivered Response not closed on late cancel (ASYNC-20); reactive SSE backpressure, source not closed, single-subscriber (ASYNC-21); concurrent-safe async transport (ASYNC-22). + +### B.8 Cross-cutting invariants (XCUT) + +- [ ] Cancellation terminal-non-retryable with the flag preserved (XCUT-1); timeout retryable with a clear flag, discriminated out-of-band, subtype-first (XCUT-2); inter-attempt wait promptly cancellable (XCUT-3). +- [ ] Two-branch error taxonomy, transport error always-retryable and I/O-family (XCUT-4); baked protocol-error flag from one classifier (408/429/all-5xx-except-501/505) (XCUT-5); capability-based classification for non-protocol errors (XCUT-6); configurable authoritative retryable-status set drives protocol-error retries (XCUT-7); status factory rejects non-error status (XCUT-8); cause-chain walks cycle-safe (XCUT-9). +- [ ] Retry-safety gate applies uniformly incl. transport errors — bare POST not retried, non-replayable body not re-sent (XCUT-10). +- [ ] Shared components concurrent-safe with per-call state local (XCUT-11); wait-free credential read + scoped single-flight refresh (XCUT-12); idempotent non-blocking close (XCUT-13); SDK closes only what it created (XCUT-22); pluggable seam resolves explicit > auto-discovery > loud-fail on zero/ambiguous (XCUT-23). +- [ ] Caller/server-keyed maps bounded with drain-to-cap loop (XCUT-14); immutable public wire models with no external-mutable alias (XCUT-15). +- [ ] No credential over non-HTTPS (XCUT-16); redirect credential hygiene — Authorization always stripped, cross-origin strips Cookie/Proxy-Authorization judged against the seed, userinfo dropped, downgrade default-denied (XCUT-17); header name/value validation against splitting at the model layer (XCUT-18); default-deny log redaction of userinfo/query/fragment/headers/credentials/bodies (XCUT-19); observability never throws into the request path (XCUT-20); CSPRNG for security-relevant randomness (XCUT-21); byte-capped non-consuming diagnostic previews (XCUT-24). + +### B.9 Non-functional quality bar (NFR) + +- [ ] Core has zero concrete runtime dependencies beyond stdlib + compile-only logging facade (NFR-1); adapters depend on core plus at most one third-party library (NFR-2). +- [ ] Public surface explicit and minimal (NFR-3); machine-comparable API snapshot gates drift, regeneration is deliberate (NFR-4). +- [ ] Aggregate coverage floor enforced by the default build (NFR-5); warnings-as-errors (NFR-6); lint/static-analysis fatal with documented scoped waivers (NFR-7); all gates automatic and blocking (NFR-17). +- [ ] Shrinker keep-configuration shipped for reflective/SPI surface (NFR-8); shrink-and-run regression guard in the default build (NFR-9). +- [ ] Declared runtime floor with higher-floor features isolated, emitted-target and visible-API agree (NFR-10). +- [ ] Core concurrency-model agnostic, no async-framework types in the public surface (NFR-11). +- [ ] Reproducible byte-identical artifacts (NFR-12); per-file license header (NFR-13); single-source-of-truth versions/coordinates (NFR-14); runtime-resolvable version metadata, never the placeholder in packaged artifacts (NFR-15); artifacts signed on the release/CI path, optional locally (NFR-16). + +## Appendix C — Consolidated Normative Requirement Index + +Every normative requirement defined in this specification, aggregated for conformance tracking. This index lists 645 requirements across 19 subsystems. + +| ID | Level | Subsystem | Requirement | +|---|---|---|---| +| SEAM-1 | MUST | Product vision, pluggable seams and extension model | The core library MUST NOT embed a concrete HTTP transport, a concrete byte-stream I/O implementation, or a concrete wire codec. It provides only the abstractions (models, pipeline, seams) and depends at runtime on nothing beyond its language's standard library plus a logging facade. Every concrete capability that touches the outside world is supplied by a separate plug-in. | +| SEAM-2 | MUST | Product vision, pluggable seams and extension model | Each external concern that has a core-owned contract MUST be exposed as exactly one narrow interface seam that the core depends on but never implements, and the core MUST NOT reference any concrete implementation of that seam by name. The enumerated core interface seams are: byte-stream provider, synchronous transport, asynchronous transport, wire codec (serde), and operation-input->request projection. The async-runtime concern is NOT a core interface seam: it is handled through the canonical async pivot (the async transport's future return type) plus out-of-core adapter modules (see SEAM-17). | +| SEAM-3 | MUST | Product vision, pluggable seams and extension model | The byte-stream provider seam MUST expose factory operations that create: a new empty in-memory buffer, a buffered reader over a raw input stream, a buffered reader over a byte array, a buffered writer over a raw output stream, and wrappers that add the buffered surface to a primitive source/sink. A reader/writer created over a caller's raw input/output stream TAKES OWNERSHIP of that stream — closing the reader/writer closes the underlying stream. | +| SEAM-4 | MUST | Product vision, pluggable seams and extension model | Byte-stream provider factory operations MUST be safe to invoke concurrently from many threads. The buffer/reader/writer instances they return are NOT required to be thread-safe; each such instance is expected to be confined to a single logical operation. | +| SEAM-5 | MUST | Product vision, pluggable seams and extension model | Provider resolution MUST follow a fixed precedence: an explicitly installed provider always wins; otherwise the runtime auto-discovers providers registered on the classpath/plugin registry. Resolution MUST throw a descriptive error when ZERO providers are discoverable (message telling the caller to install one) and when MORE THAN ONE distinct provider is discoverable (message listing all candidates so the ambiguity is obvious). Exactly one discoverable provider is selected silently. | +| SEAM-6 | MUST | Product vision, pluggable seams and extension model | Explicit installation of the byte-stream provider MUST be idempotent for the same instance (re-installing the identical provider is a no-op) and MUST reject installing a DIFFERENT provider when one is already installed, by throwing with a message naming both the incumbent and the rejected provider. A separate unchecked/internal swap seam MAY exist for test-scoped overrides. | +| SEAM-7 | MUST | Product vision, pluggable seams and extension model | Once auto-discovery has resolved exactly one provider, that result MUST be cached process-wide so the discovery scan does not repeat on every access. An UNRESOLVED state (zero or multiple candidates, which throws) MUST remain re-evaluable on the next access, so a provider registered later (or an explicit install) can still take effect. | +| SEAM-8 | SHOULD | Product vision, pluggable seams and extension model | When an explicit provider install replaces a DIFFERENT provider that had already been auto-resolved AND handed out to a caller, the runtime SHOULD emit a warning (not fail): objects may already have been created against the previously-resolved provider, risking mixed-provider state. No warning is warranted when nothing was resolved, when the resolved provider was never actually returned, or when the incoming provider is effectively the same implementation. | +| SEAM-9 | MUST | Product vision, pluggable seams and extension model | Provider resolution/install/swap state MUST be concurrency-safe: reads observe the latest install without blocking, and writes are serialized so two concurrent installs cannot both pass the conflict check, and a concurrent first-access cannot run the discovery scan twice. A port must preserve this guarantee (memory-safe publication of the installed provider to non-blocking readers, serialized writes, single-scan resolution) rather than any particular concurrency mechanism. | +| SEAM-10 | SHOULD | Product vision, pluggable seams and extension model | The registration mechanism SHOULD tolerate a single logical provider being seen through more than one registry/loader without misreporting it as multiple distinct providers. Candidates SHOULD be de-duplicated by concrete implementation identity, and a provider that is a thin shim delegating to a canonical instance SHOULD be recognizable AS that canonical instance (via an 'underlying' identity) so a shim + its target do not read as two providers or trip the mixed-provider warning. | +| SEAM-11 | MUST | Product vision, pluggable seams and extension model | The synchronous transport seam MUST be a single-operation contract: given one request, produce one response. The response body MUST NOT be pre-buffered by the transport — the caller owns reading and closing it. The transport MAY additionally accept per-call options; a transport that ignores options MUST behave identically to the no-options call (options default to being dropped). | +| SEAM-12 | MUST | Product vision, pluggable seams and extension model | Transport implementations (sync and async) MUST be safe for concurrent calls from multiple threads, with all per-request state confined to locals or to the returned response graph (sync) / the returned future's completion graph (async). | +| SEAM-13 | SHOULD | Product vision, pluggable seams and extension model | Blocking transport implementations SHOULD honor cooperative cancellation (thread interruption on the JVM) during blocking I/O and propagate it per the SDK cancellation contract; async transports SHOULD treat cancelling the returned future as a best-effort request to abort the in-flight exchange and release transport resources (sockets, descriptors). | +| SEAM-14 | MUST | Product vision, pluggable seams and extension model | Both transport seams MUST be closeable, and the close contract MUST be: (1) idempotent — repeated close is safe; (2) ownership-aware — only resources the transport itself created are released, and BYO dependencies supplied by the caller (a pre-built native client, a caller-supplied executor) are NEVER touched; (3) interrupt-safe — a close path that blocks on shutdown honors cancellation. A lightweight transport MAY have a no-op close by default. | +| SEAM-15 | MAY | Product vision, pluggable seams and extension model | After a transport's close() returns, further send calls MAY have undefined behavior — the SDK does not mandate a specific post-close failure mode (throw vs error response). A port MAY choose a mode but SHOULD document it. | +| SEAM-16 | MUST | Product vision, pluggable seams and extension model | The asynchronous transport seam's returned future MUST complete either with a non-null response (caller owns closing it) or exceptionally with the transport failure. It MUST NOT complete successfully with a null/absent value — an implementation with no response MUST complete exceptionally instead. Cancelling an already-completed success future does NOT close the delivered response body; the caller MUST still close it even when discarding the value. | +| SEAM-17 | SHOULD | Product vision, pluggable seams and extension model | The async transport contract SHOULD be expressed in terms of one canonical, dependency-free async primitive (a future that completes with a value or exceptionally) that serves as the interop pivot; ecosystem-specific facades (coroutines, reactive streams, event-loop futures, virtual threads) SHOULD be provided as separate adapter modules that bridge to and from that pivot, aiming to preserve cancellation and error semantics with per-adapter caveats documented (e.g. an executor-bridged blocking client can only interrupt on an interrupting cancel). | +| SEAM-18 | MUST | Product vision, pluggable seams and extension model | The provided sync<->async bridges MUST preserve semantics across the boundary: wrapping a blocking transport as async REQUIRES the caller to supply an executor (there is intentionally no default, and a shared global fork/join-style pool is explicitly not acceptable because a blocking call would starve it); wrapping an async transport as blocking MUST unwrap the async wrapper exception so callers observe the original failure, and the blocking wait MUST honor interruption (restore the interrupt flag, cancel the in-flight future, surface an interrupted-I/O error). Per-call options MUST be threaded through the bridge, not dropped. | +| SEAM-19 | MUST | Product vision, pluggable seams and extension model | The wire-codec (serde) seam MUST be a bundle exposing a serializer, a deserializer, and the media type its serializer produces. The media type MUST NOT be defaulted — each codec declares its own (e.g. JSON vs XML) so a non-JSON codec cannot silently stamp the wrong content type. Concrete codecs live outside the core. | +| SEAM-20 | MUST | Product vision, pluggable seams and extension model | Serializer operations MUST offer the common allocation profiles (produce a fresh string, produce a fresh byte array, stream into a caller-owned output, encode into a caller-owned scratch buffer at an offset), and streaming/buffer variants MUST NOT close the caller's target. Encode failures MUST surface as a stable SDK serialization-failure type (subtype of the serde failure type), chaining the underlying codec's error as cause; a genuine downstream stream-write I/O error propagates unwrapped, and a fixed-buffer overflow raises an index/bounds error. | +| SEAM-21 | MUST | Product vision, pluggable seams and extension model | Deserializer operations MUST require an explicit runtime type token for the target type rather than relying on an erased/inferred generic, so a language with type erasure recovers the intended type instead of silently producing a generic map/list that fails at first field access. For parametric targets a full generic type capture MUST be accepted; a format-agnostic (no-codec) deserializer MUST fail with the stable serde failure type when asked to resolve a genuinely parametric type it cannot handle. Decode failures MUST surface as a stable SDK deserialization-failure type chaining the underlying cause; a genuine stream-read I/O error propagates unwrapped, and streaming decode reads to EOF but does NOT close the caller's stream. | +| SEAM-22 | MUST | Product vision, pluggable seams and extension model | A full generic type capture used for deserialization MUST be created with a concrete type argument at the call site (on the JVM: an anonymous subclass). Construction MUST reject a capture whose argument is an unresolved type variable (i.e. captured inside a generic function/subclass where the argument is erased to its bound), because decoding it would resolve to the wrong type. The capture exposes both the full generic type and its erased raw class for a fast non-parametric path. | +| SEAM-23 | MUST | Product vision, pluggable seams and extension model | The serde seam's failure types MUST form a stable, SDK-owned hierarchy (a base serde failure with encode/decode subtypes) that adapters throw INSTEAD of leaking their backing library's exception type, always chaining the original as the cause so no diagnostic is lost. These failures are unchecked (callers are not forced to wrap every round-trip), and the base type is open for codegen/adapters to add more specific subtypes. | +| SEAM-24 | SHOULD | Product vision, pluggable seams and extension model | Async-runtime adapter modules that hand work to another thread SHOULD propagate the ambient logging/diagnostic context (e.g. MDC) across the thread handoff so log/trace events emitted on the worker carry the originating context. Each adapter's cancellation bridge SHOULD map cancellation in both directions per that ecosystem's idiom (e.g. cancelling a downstream subscription/future cancels the pivot future, and vice versa). | +| SEAM-25 | MUST | Product vision, pluggable seams and extension model | An async-runtime adapter that OWNS an executor/thread-pool resource MUST implement close() as an idempotent, ownership-aware release: only the first close shuts the owned executor down and emits the lifecycle event, later closes are true no-ops. Closing such an adapter MUST NOT be required to cancel in-flight requests (a graceful shutdown that waits for running tasks is acceptable). An adapter/bridge built over a caller-supplied executor MUST NOT shut that executor down on close. | +| SEAM-26 | MUST | Product vision, pluggable seams and extension model | The operation-input projection seam MUST let generated/operation code declare, per operation, an HTTP method, a path template with named placeholders, and typed projections of inputs onto path / query / header / body — so operation arguments (cursors, page numbers, ids) flow through typed projections rather than being spliced into a URL by string surgery. Only method and path template are required; the four projections default to empty for a parameterless operation. The body is carried, not encoded, by this seam. | +| SEAM-27 | MUST | Product vision, pluggable seams and extension model | When the operation projection is assembled against a base URL, path-parameter values MUST be percent-encoded as single path segments (so a value cannot inject extra '/' segments), every placeholder in the template MUST have a supplied value (a missing one is an error), the query MUST be RFC-3986 rendered, and base-URL composition MUST follow fixed rules: a trailing slash is normalized to exactly one separator, an empty path leaves the base untouched, an existing base query is preserved with the operation's query appended after it (its dangling separator dropped), and a base URL carrying a fragment or resolving to a malformed URL is rejected with a context-bearing error. | +| SEAM-28 | MAY | Product vision, pluggable seams and extension model | The operation projection MAY carry a stable operation identifier for instrumentation/tracing; when present it is attached to the request's context chain but MUST NOT affect the assembled request's URL, headers, or body. | +| SEAM-29 | MUST | Product vision, pluggable seams and extension model | Public model construction MUST go through the immutable-value + Builder pattern: models are immutable, constructed only via a builder whose build() materializes a new instance, and a shared generic Builder contract (build() producing the target type) MUST exist so generic composition/folding helpers can accept any builder. Required-field validation MUST be uniform: a missing required field fails at build() with a consistent, field-named error message of the form ' is required'. | +| SEAM-30 | MUST | Product vision, pluggable seams and extension model | On any path where a send produces a response value that the returned future will NOT hand to a caller — because the future was already cancelled or completed exceptionally (the completion race) — the producer MUST close that orphaned response so its underlying connection/descriptor is not leaked. This closes the gap in the caller-closes-the-response rule (SEAM-16), which cannot apply to a value no caller receives, and applies to native async transports, the sync->async bridge, and every ecosystem adapter that can drop a produced value. | +| HTTP-1 | MUST | Core HTTP domain model | All core domain-model types (Request, Response, Headers, MediaType, QueryParams, RequestOptions, RequestConditions, Status, ETag, HttpRange, the typed header name, Protocol, Method) MUST have an immutable value/metadata surface after construction, safe to share across threads without external synchronization; any change MUST produce a new instance rather than mutating an existing one. The one carve-out: a body attached to a Request/Response may carry single-use stream state whose consumption is NOT thread-safe (see the body requirements), and per-call tag values are opaque so their own mutability is the caller's concern. | +| HTTP-2 | MUST | Core HTTP domain model | Model instances MUST be constructible only through their builder or dedicated factory, never by exposing a public field-wise constructor or an unchecked copy operation that bypasses builder/factory validation. | +| HTTP-3 | MUST | Core HTTP domain model | Each builder-based model (Request, Response, Headers, QueryParams, RequestOptions, RequestConditions, and the multipart body) MUST expose a newBuilder()-style derivation returning a builder pre-populated with the instance's current fields, so a caller can derive a modified copy without restating unchanged fields; the pre-filled builder MUST NOT alias the original's internal collections (each value list is copied). Value-based types that have no builder (MediaType, Status, the typed header name, ETag, HttpRange, Method, Protocol) are instead derived by re-constructing through their factories and intentionally expose no builder. | +| HTTP-4 | MUST | Core HTTP domain model | A builder's build() MUST validate that all required fields are present and fail with a clear, field-named error when one is missing (Request requires url; Response requires request, protocol, status). Missing-field failures MUST NOT silently substitute defaults except where a default is explicitly specified. | +| HTTP-5 | MUST | Core HTTP domain model | Accessors returning collections of header/query names, values, or entries MUST NOT let a caller mutate the model through the returned value, and MUST NOT surface later mutations of a live builder. Name-set and entry-set accessors return a fresh per-call snapshot; per-name value-list accessors return the instance's own list. Isolation from a builder is guaranteed by a build-time deep copy of every value list; isolation from mutation-through-the-collection is guaranteed by returning read-only-typed collections. A port in a language without read-only collection views MUST reproduce this guarantee with unmodifiable wrappers or per-call defensive copies, or a consumer could downcast a returned value list and mutate the shared model. | +| HTTP-6 | MUST | Core HTTP domain model | A Request MUST carry exactly method, target URL, headers, and an optional body; headers MUST always be a non-null (possibly empty) collection. A Response MUST carry the originating request, negotiated protocol, status, an optional reason phrase, headers (non-null, possibly empty), and an optional body. | +| HTTP-7 | MUST | Core HTTP domain model | The builder MUST reject a non-null request body on any method whose classification forbids a body (GET, HEAD, TRACE, CONNECT), failing at construction rather than deferring to the transport. To downgrade a body-carrying request to such a method, the caller clears the body first. | +| HTTP-8 | SHOULD | Core HTTP domain model | When no method is set, build() SHOULD default to GET only if no body is present; if a body is present but no method was set, build() SHOULD fail reporting a missing method (a forgotten verb) rather than defaulting to GET and then tripping the no-body-on-GET rule with a misleading message. | +| HTTP-9 | MUST | Core HTTP domain model | The model MUST define an idempotency classification used by retry logic — the idempotent method set is {GET, HEAD, OPTIONS, PUT, DELETE} — and this set MUST be the single source both the configurable retry allow-list and the inherent replay-safety gate derive from. (There is no separate safe-method classification, and the idempotent set is an internal constant rather than a public accessor on the method type.) A method's canonical wire token MUST equal its uppercase name and be usable directly in a request line without translation. | +| HTTP-10 | MUST | Core HTTP domain model | Status MUST be a total function of the integer code: mapping any code MUST return a Status (never throw), returning a canonical named instance for recognized codes and an instance carrying the raw code with a null/absent name for unrecognized ones. A separate lookup MUST allow callers to distinguish recognized codes (returns null/absent for unknown). | +| HTTP-11 | MUST | Core HTTP domain model | Status MUST classify by numeric range: informational 100-199, success 200-299, redirect 300-399, client-error 400-499, server-error 500-599, and error 400-599. Response MUST expose these same classifications derived from its status. | +| HTTP-12 | MUST | Core HTTP domain model | Two Status values MUST be equal iff their numeric codes are equal (name does not participate), so a code-derived Status compares equal to the corresponding canonical constant. | +| HTTP-13 | MUST | Core HTTP domain model | Header names MUST be treated case-insensitively for storage, lookup, containment, mutation, removal, equality, and hashing, by folding to lower case using an ASCII/invariant rule (never a locale-sensitive fold such as Turkish i). A header added under one casing MUST be visible under any other casing. | +| HTTP-14 | MUST | Core HTTP domain model | The header model MUST support multiple values per name: an add operation appends to the name's value list; a set operation replaces the entire list for that name. Per-name value order MUST be preserved in insertion order. | +| HTTP-15 | MUST | Core HTTP domain model | Setting a header value to null MUST remove the header entirely (equivalent to remove), rather than storing a null or empty value. | +| HTTP-16 | SHOULD | Core HTTP domain model | The header model SHOULD preserve the insertion order of distinct names so serialization and iteration are deterministic and reproducible. | +| HTTP-17 | MUST | Core HTTP domain model | Outbound (caller-set) header names MUST be validated at construction: reject a blank name, and reject any name containing a control character (C0 range 0x00-0x1F or DEL 0x7F, which includes CR, LF, NUL) or any non-ASCII byte (>= 0x80). Surrounding whitespace MUST be trimmed before validation, and the trimmed name stored. | +| HTTP-18 | MUST | Core HTTP domain model | Outbound (caller-set) header values MUST be validated: reject any control character (C0 0x00-0x1F and DEL 0x7F) EXCEPT horizontal tab 0x09, and reject any non-ASCII byte (>= 0x80). The accepted set is HTAB plus printable ASCII 0x20-0x7E. | +| HTTP-19 | MUST | Core HTTP domain model | The model MUST provide a distinct lenient path for inbound (already-received / response) header values that RELAXES the non-ASCII rule (obs-text 0x80+ is permitted) while STILL rejecting control characters (C0 except HTAB, plus DEL). Header names on the inbound path remain strictly validated. | +| HTTP-20 | MUST | Core HTTP domain model | Validation error messages MUST NOT echo the offending header value verbatim, and MUST escape any control characters in an echoed header name (e.g. as \uXXXX), so a rejected CR/LF/NUL or a secret/oversized value never lands raw in a log line. | +| HTTP-21 | MUST | Core HTTP domain model | A typed header-name abstraction MUST compare equal and hash by its case-folded form while preserving the original casing for wire emission/logging; it MUST be interchangeable with the string-keyed header API (a name added via one form is visible via the other) and MUST enforce the same name validation as HTTP-17. | +| HTTP-22 | MAY | Core HTTP domain model | The typed header-name abstraction MAY intern instances process-wide so repeated lookups of the same name share one instance, with the first caller's casing winning. This is an optimization; the observable contract is value equality by case-folded name (HTTP-21), not reference identity. | +| HTTP-23 | MUST | Core HTTP domain model | MediaType construction MUST lower-case the type, subtype, and every parameter KEY, but MUST preserve the case of every parameter VALUE. Equality MUST therefore be case-insensitive on type/subtype/param-keys and case-sensitive on param-values. | +| HTTP-24 | MUST | Core HTTP domain model | MediaType MUST resolve its charset parameter case-insensitively and MUST return null (not throw) when the parameter is absent or names an unknown/unsupported charset, so callers can fall back to a default without exception handling. | +| HTTP-25 | MUST | Core HTTP domain model | MediaType parsing MUST split parameters while respecting quoted-strings (a ';' or '=' inside double quotes is not a separator), MUST split each parameter on its FIRST '=' only, and MUST strip surrounding quotes and unescape quoted-pairs (\" → ", \\ → \). Rendering MUST emit a parameter value bare when it is a valid token and as an escaped quoted-string otherwise, so parse(render(x)) == x for every constructible MediaType (including boundaries containing ';' or '='). | +| HTTP-26 | MUST | Core HTTP domain model | MediaType construction MUST reject a control character (C0 except HTAB, plus DEL) or a non-ASCII byte in the type, subtype, or any parameter key/value, using the same predicate as outbound header-value validation, so a media type can always be emitted as a Content-Type header without a late rejection or a CR/LF injection. | +| HTTP-27 | SHOULD | Core HTTP domain model | MediaType SHOULD support wildcard matching where a wildcard type is permitted only when the subtype is also a wildcard (bare */*), and an includes()/matches operation treats a wildcard in either position as matching any value with parameters ignored (a wildcard-subtype receiver matches concrete subtypes but not the reverse). | +| HTTP-28 | MUST | Core HTTP domain model | QueryParams MUST treat parameter names as case-sensitive (no folding), preserve insertion order, support multiple values per name, and model a value-less parameter (?flag) as a single empty-string value distinct from an absent name. | +| HTTP-29 | MUST | Core HTTP domain model | QueryParams.encode() MUST render each name and value with RFC 3986 percent-encoding (space → %20, literal '+' → %2B, '/' → %2F, '*' → %2A), percent-encoding everything except the unreserved set A-Z a-z 0-9 - . _ ~, MUST preserve insertion order, MUST emit a repeated name once per value (a=1&a=2), MUST omit the leading '?', and MUST return an empty string when empty. This is NOT application/x-www-form-urlencoded (which uses '+' for space). | +| HTTP-30 | MUST | Core HTTP domain model | QueryParams equality MUST be order-sensitive: two instances are equal iff they carry the same names and values in the same order — i.e. iff they encode() identically. A name whose value list is empty MUST be dropped at build time (it renders nothing) so it cannot leave a phantom contains()==true entry invisible to encode(). | +| HTTP-31 | MUST | Core HTTP domain model | QueryParams.parse() MUST invert encode() for names/values/order and MUST be lenient on input: a null or blank query yields empty; a leading '?' is tolerated and stripped; a segment with no '=' and a segment with a trailing '=' both yield an empty-string value; empty segments (stray '&') are skipped; and malformed percent-encoding falls back to the raw text rather than throwing. | +| HTTP-32 | SHOULD | Core HTTP domain model | Percent-encoding of a single component SHOULD encode using RFC 3986 unreserved-set rules independent of any underlying library quirks: a space becomes %20 (never '+'), a literal '+' becomes %2B, and decoding MUST leave a literal '+' as '+' (never a space). '~' MUST remain unencoded and '*' MUST be encoded. | +| HTTP-33 | MUST | Core HTTP domain model | Protocol MUST expose a canonical lower-case wire form (e.g. http/1.1, http/2) and a case-insensitive parse that accepts the canonical forms plus the aliases HTTP/2 and HTTP/2.0 for HTTP/2, throwing on an unrecognized identifier. Case folding MUST be locale-invariant. | +| HTTP-34 | MUST | Core HTTP domain model | RequestOptions MUST model per-call operational overrides that are NOT part of the wire form (at minimum: per-call timeout, per-call max-retries, and opaque string-keyed tags), with every field defaulting to a null/empty 'use the transport/pipeline default' sentinel, and MUST provide a canonical EMPTY 'override nothing' instance used when a caller supplies no options. Tags MUST be defensively copied at build. | +| HTTP-35 | MUST | Core HTTP domain model | RequestOptions builder MUST reject a non-null timeout that is zero or negative and MUST reject a negative max-retries, because those values carry inconsistent or opposite meanings downstream (zero-timeout means 'no timeout' in one transport but is an error in another; a negative retry count would be silently reinterpreted as 'use default'). A max-retries of 0 MUST be accepted and MUST mean 'disable retries for this call'. | +| HTTP-36 | MUST | Core HTTP domain model | A request body MUST produce its bytes on demand via a single write-to-sink operation, MUST report its media type (nullable) and content length (with a negative sentinel, -1, meaning 'unknown'), and MUST expose whether it is replayable — i.e. whether writing it more than once yields identical bytes. | +| HTTP-37 | MUST | Core HTTP domain model | A single-use (non-replayable) body MUST fail loudly on a second write attempt (throwing, not silently emitting zero bytes), and the second-write guard MUST be race-safe so that under concurrent writes at most one write proceeds and the loser observes a clear error. toReplayable() on a single-use body MUST drain it once into memory, MUST return a replayable equivalent, and MUST leave the original consumed (the caller continues with the returned value). | +| HTTP-38 | MUST | Core HTTP domain model | Body factories MUST classify replayability by source: byte-array, string, in-memory buffer, file, and serialized-object bodies are replayable; a body backed by a one-shot source/stream is single-use UNLESS the stream supports mark/reset AND the declared length fits the mark limit, in which case it MAY be replayable via reset. A form-urlencoded body MUST be replayable and MUST use x-www-form-urlencoded encoding ('+' for space) distinct from RFC 3986 query encoding. | +| HTTP-39 | MUST | Core HTTP domain model | A body that copies a fixed number of bytes from a source MUST write exactly that many bytes and MUST raise an end-of-input error if the source ends early (it MUST NOT silently send a short body or spin on a zero-length read). | +| HTTP-40 | MUST | Core HTTP domain model | A file-backed body MUST validate fail-fast at construction: the file must exist, must be a regular file (not a directory/special file), the start offset must be non-negative and within the file size, and offset+count must not exceed the file size captured at construction. It MUST be replayable, opening a fresh file handle on each write, and MUST expose the exact byte count it will upload. | +| HTTP-41 | MUST | Core HTTP domain model | A response body MUST be single-use (its byte source can be read only once; repeated accessors return the same underlying source) and MUST require an explicit, idempotent close that releases the underlying transport connection even when the body was never read. Convenience readers that materialize the whole body (as string or byte array) MUST close the body in a finally block whether or not the read succeeds. | +| HTTP-42 | MUST | Core HTTP domain model | Reading a response body as text MUST default its charset to the charset declared in the body's media type, falling back to UTF-8 when none is declared or the declared charset is unknown. | +| HTTP-43 | MUST | Core HTTP domain model | Response MUST be closeable and its close MUST be idempotent and forward to the body, so a response can be released in a scoped/using block whether or not its body was consumed. (Idempotency is delegated to the body's idempotent close per HTTP-41; a bodyless response close is a no-op.) | +| HTTP-44 | MUST | Core HTTP domain model | A lazy typed-response wrapper MUST expose raw status/headers/protocol/reason/request WITHOUT consuming the body, and MUST parse the typed value at most once on first access, memoizing the outcome so every later access returns the same value or re-throws the same failure without re-running the handler or re-reading the single-use body. Both a null success and a thrown failure MUST be memoized. | +| HTTP-45 | MUST | Core HTTP domain model | Concurrent first accesses to the lazy typed value MUST be serialized so the handler runs exactly once. The serializing primitive MUST NOT pin/park the underlying OS carrier thread for the duration of the parse — portable intent: use a lock that cooperates with lightweight/virtual-thread schedulers rather than an intrinsic monitor that holds and pins the carrier across the parse. | +| HTTP-46 | MUST | Core HTTP domain model | Request URL equality/hashing MUST NOT perform blocking work or name resolution (DNS): URLs MUST be compared by their textual external form. Request equality MUST otherwise compare method, headers, and body by value. | +| HTTP-47 | SHOULD | Core HTTP domain model | Building a Request from a malformed URL string or a non-absolute URI SHOULD fail with an argument error carrying the offending input, rather than surfacing a lower-level or transport-specific error later. | +| HTTP-48 | SHOULD | Core HTTP domain model | An ETag helper SHOULD model the three RFC 7232 forms — strong ("opaque"), weak (W/"opaque"), and the any singleton (*) — validate that a supplied opaque value contains only permitted etagc characters (rejecting a literal double-quote, control characters, and DEL, permitting obs-text), reject an empty strong opaque, permit an empty weak opaque, and round-trip its raw header form through parse/render preserving the exact text. parse MUST reject unterminated forms and return absent for blank input. | +| HTTP-49 | SHOULD | Core HTTP domain model | An HTTP Range helper SHOULD provide validated factories for a bounded byte range (offset+length, rejecting negative offset / non-positive length and detecting overflow), a suffix range (last N bytes), and an open-ended range (offset to end), SHOULD support only the 'bytes' unit and only a single range (rejecting multi-range commas), and SHOULD store a parsed value verbatim so its casing round-trips. | +| HTTP-50 | SHOULD | Core HTTP domain model | A conditional-requests aggregator SHOULD emit If-Match/If-None-Match as a single comma-separated header per list, emit If-Modified-Since/If-Unmodified-Since as RFC 1123 dates, be idempotent when applied to a header set (using set, not add), and enforce that the any-tag (*) is mutually exclusive with concrete entity-tags in the same list (collapsing repeated * to one). | +| HTTP-51 | SHOULD | Core HTTP domain model | A multipart body SHOULD derive its declared content length and its written bytes from a single shared framing routine (so the declared length can never drift from the bytes written), SHOULD report replayable only when every part body is replayable and length-known only when every part body has a known length, SHOULD generate a spec-valid random boundary and reject a caller-supplied boundary that violates the RFC 2046 grammar (1-70 chars from bcharsnospace), and MUST quote/escape part header parameter values so CR/LF or a quote cannot break the framing. | +| HTTP-52 | MUST | Core HTTP domain model | An error-mapping path that must retain a response body after the transport connection is released MUST buffer the body into memory up to a fixed cap (1 MiB), dropping bytes beyond the cap, and MUST expose the buffered copy as a replayable body readable multiple times. Buffering MUST occur inside the original body's close scope so a provider-resolution failure still releases the connection. | +| HTTP-53 | MUST | Core HTTP domain model | Parsing a media-type string MUST reject blank input and MUST require a well-formed type/subtype: a '/' must be present with a non-empty type before it and a non-empty subtype after it (a bare word, a leading-slash, and a trailing-slash form are all rejected). Each parameter segment MUST contain a '=', with a non-empty key and a non-empty raw value, or parsing fails. | +| IO-1 | MUST | I/O streaming contracts | Source.read(dest, byteCount) MUST append the bytes it reads to the TAIL of the caller-provided destination buffer (never overwrite existing content), and MUST return the number of bytes transferred: at least 1 when byteCount>0 and the source is not exhausted, exactly 0 when byteCount==0, -1 when the source is exhausted before any byte is read, and never more than byteCount. | +| IO-2 | MUST | I/O streaming contracts | A read of byteCount==0 MUST return 0 and MUST NOT report end-of-stream (-1), even when the source is already exhausted. | +| IO-3 | MUST | I/O streaming contracts | A negative byteCount passed to a size-taking read/write/copy operation MUST be rejected as an argument-validation (programming) error before any I/O occurs, rather than silently clamped. Note the reference raises this as an IllegalArgumentException for Source.read / Sink.write and as an IndexOutOfBoundsException (a bounds error) for Buffer.copyTo; a port MAY use whichever argument-error type is idiomatic, so long as it fails fast. | +| IO-4 | MUST | I/O streaming contracts | Sink.write(src, byteCount) MUST remove exactly byteCount bytes from the HEAD of the source buffer and push them downstream; if the source holds fewer than byteCount bytes this MUST fail with an I/O error rather than write a short/partial amount. | +| IO-5 | MUST | I/O streaming contracts | Sink MUST expose flush() that pushes currently-buffered bytes toward their final destination, and both Source and Sink MUST be closeable. | +| IO-6 | MUST | I/O streaming contracts | When a provider wraps a caller-supplied underlying stream (readable stream -> buffered source, writable stream -> buffered sink), the returned wrapper MUST take ownership of that stream: closing the wrapper closes the underlying stream. The same holds for the stream bridges obtained from a buffered source/sink (closing the bridge closes the owning source/sink). Wrapping a plain byte array owns no external resource. | +| IO-7 | MUST | I/O streaming contracts | A Buffer MUST behave as a FIFO byte queue that is simultaneously a source and a sink: bytes written through its sink surface MUST be read back through its source surface in the exact order written, and its size MUST reflect the number of bytes currently held. | +| IO-8 | MUST | I/O streaming contracts | Buffer.snapshot() MUST return a fresh, independent byte-array copy of the buffer's current contents without consuming or otherwise mutating the buffer, such that later mutations of the buffer do not affect a previously returned snapshot and vice versa. | +| IO-9 | SHOULD | I/O streaming contracts | Materializing an entire buffer as one contiguous byte array via snapshot() SHOULD refuse sizes that exceed the host's maximum single-array allocation, failing loudly with an actionable message that points callers at streaming alternatives (stream bridge / copyTo) rather than crashing with a low-level allocation error. Length-bounded slice reads apply the same guarded, message-bearing cap. Plain (non-slice) exact-count buffered reads instead inherit whatever bounds check the underlying stream library performs, which may raise a less specific error. | +| IO-10 | MUST | I/O streaming contracts | Buffer.clear() MUST discard every byte (leaving size==0), and Buffer.copyTo(out, offset, byteCount) MUST copy the specified window into another buffer WITHOUT consuming or mutating the source buffer, defaulting to 'from offset through end' when byteCount is omitted, and rejecting out-of-range windows (negative offset/byteCount or offset+byteCount>size). | +| IO-11 | MUST | I/O streaming contracts | exhausted() MUST return true exactly when no more bytes are available (and may block while it waits on an upstream source to decide), readByte() MUST return the next byte or fail with an EOF error when none remain, and readByteArray() (no count) MUST return all remaining bytes (empty array when already exhausted). | +| IO-12 | MUST | I/O streaming contracts | An exact-count read (readByteArray(n) / readUtf8(n)) MUST return exactly n bytes/characters or fail with an EOF error if fewer than n remain; it MUST NOT return a short result. | +| IO-13 | MUST | I/O streaming contracts | UTF-8 and explicit-charset reads MUST decode the requested bytes with the specified encoding: readUtf8()/readString(charset) drain and decode all remaining bytes, and the write side MUST provide the symmetric encodings (writeUtf8, writeUtf8(substring range), writeString(charset)). | +| IO-14 | MUST | I/O streaming contracts | readUtf8Line() MUST read up to and consume the next line terminator and return the preceding bytes decoded as UTF-8, treating both '\n' and '\r\n' as terminators; it MUST return null when the source is exhausted before any byte is read; a final line with no terminator MUST be returned as-is; and a lone '\r' not followed by '\n' MUST be kept as part of the line's content. | +| IO-15 | MUST | I/O streaming contracts | skip(byteCount) MUST advance past exactly byteCount bytes, failing with an EOF error if fewer remain (skip(0) MUST be a no-op even at/after EOF). | +| IO-16 | SHOULD | I/O streaming contracts | A BufferedSource SHOULD provide a read-only host-native byte-stream bridge whose single-byte read returns the next byte as an unsigned value 0..255 or -1 at end, and whose bulk read returns the count read or -1 at end; closing the bridge MUST close (or invalidate) the owning source. Symmetrically a BufferedSink SHOULD provide a writable-stream bridge whose close closes the sink. | +| IO-17 | MUST | I/O streaming contracts | writeAll(source) MUST pump the source to exhaustion into the sink and return the total number of bytes transferred; it MUST terminate only on a -1 (EOF) read. When pumping a foreign (non-adapter-native) source, a read that returns 0 for a non-zero requested count MUST be treated as a source contract violation and raised as an I/O error (not tolerated as EOF and not spun on forever). | +| IO-18 | SHOULD | I/O streaming contracts | The sink surface SHOULD distinguish emit() from flush(): emit pushes buffered bytes one level toward their destination (a cheap hand-off, e.g. staging buffer -> underlying stream) without forcing a system-level flush, while flush forces bytes all the way out. On a pure in-memory buffer both MAY be no-ops that simply return self. | +| IO-19 | MUST | I/O streaming contracts | peek() MUST return a non-consuming view over the whole remaining source such that reads from the peek view do not advance the original source's cursor. | +| IO-20 | MUST | I/O streaming contracts | slice(offset, byteCount) MUST return a non-consuming, length-bounded view exposing at most byteCount bytes starting offset bytes ahead of the current cursor; reads from the slice MUST NOT advance the parent source, and reading past the window MUST behave as end-of-window (short read / EOF as appropriate to the read form). | +| IO-21 | MUST | I/O streaming contracts | Slice offset overflow MUST be detected LAZILY: constructing a slice whose offset exceeds the source size MUST succeed, and the overflow MUST surface only on first read as an empty/EOF result (empty array or empty string for drain-style reads, EOF error for exact/byte reads, null for line reads). Negative offset or negative byteCount MUST be rejected eagerly at construction. | +| IO-22 | MUST | I/O streaming contracts | Closing a slice MUST NOT close its parent source and MUST NOT advance the parent's cursor; conversely, closing the parent source MUST invalidate every outstanding slice derived from it so that subsequent reads on those slices fail loudly (a state/IO error), never returning stale or arbitrary bytes. | +| IO-23 | MUST | I/O streaming contracts | Multiple slices (and peeks) of the same source MUST be mutually independent (each has its own cursor and byte budget), and a slice-of-a-slice MUST compose offsets additively and cap its window at the outer slice's remaining bytes. | +| IO-24 | MUST | I/O streaming contracts | Reading from a slice AFTER it has been explicitly closed MUST fail loudly (a state error) for every read form, distinct from normal EOF. | +| IO-25 | MUST | I/O streaming contracts | A TeeSink MUST mirror the bytes written through it into an in-memory tap buffer AND forward the full, untruncated payload to its primary sink; the bytes delivered to the primary (the wire body) MUST never be reduced or altered by the presence of the tap. | +| IO-26 | MUST | I/O streaming contracts | A TeeSink MUST support a tap capacity limit that bounds how many bytes are mirrored into the tap; once the limit is reached, further writes MUST stop copying into the tap while still forwarding the FULL payload to the primary. The default limit MUST be effectively unbounded, and a limit of 0 MUST mirror nothing while still forwarding everything. | +| IO-27 | MUST | I/O streaming contracts | A TeeSink MUST mirror the attempted bytes into the tap BEFORE forwarding them to the primary sink, so that if the primary write fails mid-stream the attempted bytes are still captured in the tap; and its staging buffer MUST be cleared even on a failed primary write so a later write does not prepend stale bytes. | +| IO-28 | MUST | I/O streaming contracts | A TeeSink MUST NOT expose direct access to a backing buffer (attempting it MUST fail with a clear error directing callers to the typed write methods), because direct buffer writes would reach only the tap or only the primary and silently corrupt the wire body. | +| IO-29 | MUST | I/O streaming contracts | A TeeSink's own flush(), close(), and emit() MUST forward to the PRIMARY sink only (not the tap), so lifecycle/flush semantics of the real destination are preserved and the in-memory tap is left intact for later snapshotting. (The separate writable-stream bridge it exposes may additionally flush/close its own in-memory tap stream, which is harmless.) | +| IO-30 | MUST | I/O streaming contracts | The provider seam MUST offer factory operations to (a) create a new empty in-memory Buffer, (b) wrap a caller-supplied readable stream as a BufferedSource and a byte array as a BufferedSource, (c) wrap a caller-supplied writable stream as a BufferedSink, and (d) wrap a foreign primitive Source/Sink with the typed buffered surface. Each Buffer produced MUST be a fresh, independent, empty instance, and the byte-array-wrapping source MUST be an independent copy of the input (later mutation of the input array does not change the source, and vice versa). | +| IO-31 | MUST | I/O streaming contracts | The provider registry MUST resolve the active implementation with this precedence: (1) an explicitly installed provider always wins; (2) otherwise a single implementation is auto-discovered from the runtime's plugin/registration mechanism; (3) if auto-discovery finds zero implementations OR more than one, resolution MUST fail loudly with an actionable message (the multiple-candidate message naming all candidates). | +| IO-32 | MUST | I/O streaming contracts | Installing a provider MUST be idempotent for the SAME instance (re-installing the already-installed provider is a no-op) and MUST reject installing a DIFFERENT provider while one is already installed, failing loudly (naming both and pointing to a scoped-override mechanism) rather than silently overwriting. | +| IO-33 | MUST | I/O streaming contracts | An explicit install MUST win even after auto-discovery has already resolved (and possibly handed out) a provider; installing the same underlying implementation that was auto-resolved MUST NOT be treated as a conflict. | +| IO-34 | SHOULD | I/O streaming contracts | A successful auto-resolution SHOULD be cached process-wide so the discovery scan does not repeat; an UNRESOLVED state (zero or multiple candidates, which fails) SHOULD be re-evaluated on the next access so that later classpath/registration changes or a subsequent explicit install can succeed. | +| IO-35 | SHOULD | I/O streaming contracts | When an explicit install replaces a DIFFERENT provider that had already been auto-resolved AND handed out to a caller, the registry SHOULD surface a warning (not a failure) that objects created earlier may reference the previously-resolved provider, since late replacement risks mixed-provider state. No warning should fire on a first install, when nothing was handed out, or when the same underlying implementation is installed. | +| IO-36 | MAY | I/O streaming contracts | On runtimes with hierarchical plugin/classloader scopes, auto-discovery MAY consult the thread-context (child) scope before the defining (parent) scope and MUST de-duplicate candidates by concrete implementation identity so a provider visible through both scopes is counted once (not misreported as an ambiguous multi-provider setup). Ports on runtimes without such scoping MAY ignore this ordering while still honoring IO-31. | +| IO-37 | MUST | I/O streaming contracts | All streaming instances (Source, Sink, BufferedSource, BufferedSink, Buffer, TeeSink) are single-threaded contracts: they are NOT required to be safe for concurrent use, and callers MUST serialize external access when sharing one across threads. Independent views (slices, peeks) MAY be used from different threads, but each individual view remains single-threaded. | +| IO-38 | MUST | I/O streaming contracts | Even though individual instances are single-threaded, the CLOSE state of a source/buffer MUST be observable across threads to the slices derived from it, so that a close on one thread reliably invalidates a slice being read on another (no torn or stale reads). | +| IO-39 | SHOULD | I/O streaming contracts | The provider REGISTRY (the global holder) SHOULD support lock-free reads of the active provider (so hot-path callers never block) while serializing installs/swaps under a non-blocking lock that does not pin/park carrier threads under lightweight-thread (Loom-style) schedulers. | +| IO-40 | MUST | I/O streaming contracts | These streaming contracts MUST NOT impose their own read/write timeout or deadline; the adapter wraps foreign streams with a no-op timeout, delegating all deadline enforcement to the transport. A mirroring/wrapping sink MUST NOT swallow OR duplicate the wrapped stream's cancellation/interrupt handling — it forwards operations to the primary and relies on the primary to honor the runtime's interrupt signal. The prompt-cancellation-of-blocked-I/O guarantee itself belongs to the wrapped transport, not to these contracts (the in-memory buffers never block). | +| IO-41 | MUST | I/O streaming contracts | Closing a source, sink, or buffer MUST be idempotent: a second (or later) close() MUST NOT throw, and the underlying resource MUST be closed at most once. | +| IO-42 | MUST | I/O streaming contracts | A buffered source/sink that wraps an external stream MUST reject read/write/flush/emit attempts made after close() with an I/O error, so use-after-close of a real resource fails loudly. A purely in-memory buffer is exempt on its OWN read/write surface (an in-memory close frees nothing and may remain readable so snapshot-after-close logging still works), but its close() MUST still invalidate every slice derived from it (see IO-22/IO-38). | +| BODY-1 | MUST | Request/response body and body-logging lifecycle | A request body MUST expose a boolean replayability property. It defaults to false (single-use). It MUST be true only when the body's write operation can be invoked more than once and produces byte-for-byte identical output on every invocation. | +| BODY-2 | MUST | Request/response body and body-logging lifecycle | A composite/aggregate body (e.g. multipart) MUST report itself replayable if and only if every constituent part is replayable, and its declared content length MUST collapse to 'unknown' if any part's length is unknown. | +| BODY-3 | MUST | Request/response body and body-logging lifecycle | A materialize-once operation (toReplayable) MUST return the same body unchanged when it is already replayable, and otherwise MUST drain the body's write output exactly once into an in-memory buffer and return a replayable buffer-backed body. After it is invoked on a single-use body, the original body MUST be treated as consumed and MUST NOT be written again. | +| BODY-4 | MUST | Request/response body and body-logging lifecycle | Retry, redirect, and authentication-challenge replay MUST all consult the body's replayability before re-sending a body-bearing request: such a request is eligible for re-send only when its body reports replayable, and a consumed single-use body MUST NOT be re-sent on any of these paths. The three paths query the same property but differ in how they decline a non-replayable body, and a port need not unify the decline behavior: the retry path stops retrying and surfaces the last outcome, the auth path returns the original challenge response unchanged (and does NOT close it), and the redirect path fails loudly by raising an error. | +| BODY-5 | MUST | Request/response body and body-logging lifecycle | On the retry path, a body-less request's re-send eligibility MUST be gated on method idempotency rather than replayability: only idempotent methods may be retried when there is no body. This gate is specific to retries; the redirect path re-sends body-less requests per redirect semantics and does not consult idempotency. | +| BODY-6 | MUST | Request/response body and body-logging lifecycle | A single-use request body MUST fail loudly (raise a clear error) on a second write attempt rather than silently emitting zero bytes. This applies to source-backed and one-shot stream-backed bodies once their underlying source is exhausted/closed. | +| BODY-7 | MUST | Request/response body and body-logging lifecycle | The consume-once guard of a single-use body MUST be race-safe: under concurrent writes at most one writer may pass the guard; every other concurrent writer MUST observe the already-consumed failure. (JVM incidental: an atomic compare-and-set; a port uses its platform's equivalent atomic/once primitive.) | +| BODY-8 | MUST | Request/response body and body-logging lifecycle | A single-use request body that owns a closeable source MUST release (close) that source as part of its single write, so that skipping materialization does not leak it. In the reference implementation this holds for the buffered-source-backed body, whose write drains-and-closes the source. Note the raw byte-stream-backed bodies do NOT close their stream during the write: the rewindable variant must keep it open to replay, and the one-shot variant leaves the caller-supplied stream unclosed per its documented ownership. A port MUST decide its stream-ownership/close rule deliberately rather than assume every single-use body closes its input. | +| BODY-9 | SHOULD | Request/response body and body-logging lifecycle | A stream-backed request body of known length SHOULD be treated as replayable when (and only when) the stream supports mark/reset and the length fits the platform's maximum single-array bound; otherwise it MUST be single-use. When replayable via mark/reset, each write after the first MUST rewind (reset) before reading, and the rewind MUST be race-safe (at most one reset between any two writes). | +| BODY-10 | MUST | Request/response body and body-logging lifecycle | An exact-length copy from a stream into the sink MUST write precisely the declared number of bytes; a premature end of stream MUST raise an end-of-file error naming how many bytes of how many were delivered, and a zero-length read for a positive request MUST be treated as a stream-contract violation (error), never as an infinite spin or as end-of-stream. A declared length of zero MUST be a legitimate empty write. | +| BODY-11 | MUST | Request/response body and body-logging lifecycle | A file-backed request body MUST be replayable, MUST open a fresh file handle for each write (so concurrent/repeated sends are safe at the body level), and MUST validate fail-fast at construction: the path must exist and be a regular file, offset must be non-negative, count must be non-negative or the 'rest of file' sentinel, and offset+count must not exceed the file size captured at construction. | +| BODY-12 | SHOULD | Request/response body and body-logging lifecycle | A file-backed request body SHOULD stream its bytes using the platform's most efficient file-to-sink transfer (avoiding an unnecessary user-space copy), and the transport layer SHOULD be able to recognize a file-backed body by type to dispatch a true zero-copy kernel transfer (sendfile-style) where available. The portable intent: uploading a large file must not require buffering it in the heap and should minimize copies. | +| BODY-13 | MUST | Request/response body and body-logging lifecycle | A file-backed transfer MUST detect a short write (fewer bytes transferred than the declared count) and raise an error naming transferred-of-total, rather than completing silently with a truncated body. | +| BODY-14 | MUST | Request/response body and body-logging lifecycle | A response body MUST be single-use: its read handle (source) is obtained once and, once consumed, the bytes are gone; requesting the handle repeatedly MUST return the same underlying handle, not a fresh replay. Repeatable/non-destructive access MUST require an explicit buffering wrapper. | +| BODY-15 | MUST | Request/response body and body-logging lifecycle | Closing a response body MUST release the underlying transport resource, MUST be idempotent (second and later closes are no-ops), and MUST NOT assume the body was read — a caller that skips the body entirely still relies on close to release the connection. | +| BODY-16 | MUST | Request/response body and body-logging lifecycle | Convenience readers that consume a response body fully (read-as-string, read-as-bytes) MUST close the body in a finally-style guarantee whether or not the read succeeds. | +| BODY-17 | MUST | Request/response body and body-logging lifecycle | The request-logging wrapper MUST mirror the exact bytes produced by the wrapped body's single write into an internal tap while forwarding those same bytes to the transport sink, consuming the upstream exactly once (never twice). The full payload MUST always reach the transport sink regardless of any tap cap; logging MUST NOT alter or truncate the bytes on the wire. | +| BODY-18 | MUST | Request/response body and body-logging lifecycle | The request-logging tap MUST be cleared at the start of every write so that a post-write snapshot reflects only the most recent write attempt; retries against a replayable delegate MUST NOT accumulate bytes from earlier attempts. | +| BODY-19 | MUST | Request/response body and body-logging lifecycle | The request-logging tap MUST be bounded by a configurable cap: once the cap's worth of bytes has been mirrored, further bytes MUST stop being copied into the tap while the full payload continues to the transport. A multi-GB upload MUST mirror only a bounded preview into memory. (The unbounded default cap exists for direct wrapper use; the instrumentation layer always supplies a finite cap.) | +| BODY-20 | SHOULD | Request/response body and body-logging lifecycle | If the wrapped body's write fails partway, the request-logging snapshot SHOULD return the bytes mirrored up to the failure point, to aid diagnosis of a failed request. The tap MUST mirror the attempted bytes before (or at least not after) forwarding to the primary, so a primary-side failure still leaves the failing chunk captured. | +| BODY-21 | MUST | Request/response body and body-logging lifecycle | The request-logging wrapper MUST expose the delegate's replayability verbatim, and its materialize-once MUST return a wrapper around the delegate's replayable form (preserving the configured tap cap), so retry loops keep capturing bytes for every attempt. | +| BODY-22 | MUST | Request/response body and body-logging lifecycle | The response-logging wrapper MUST drain the delegate at most once, lazily, on the first access (read/snapshot/exception query), buffering up to a configurable byte cap. Concurrent first accesses MUST be serialized so the upstream is read exactly once. (JVM incidental: double-checked locking with a volatile field guarded by a reentrant mutex chosen so it does not pin the underlying carrier thread; a port uses an equivalent once-latch/mutex that does not pin threads.) | +| BODY-23 | MUST | Request/response body and body-logging lifecycle | When the whole response body fits within the cap (end-of-stream reached before the cap), the wrapper MUST capture it entirely, close the delegate, and thereafter serve every read as a fresh non-consuming view of the captured bytes — fully repeatable. Repeated reads in this regime MUST each succeed independently. | +| BODY-24 | MUST | Request/response body and body-logging lifecycle | When the response body exceeds the cap (cap hit with bytes still pending), the wrapper MUST buffer only the prefix, MUST leave the delegate open, and MUST serve the next read as a single-use stream that first replays the captured prefix and then continues from the still-live tail so the consumer receives the complete body. A second read in this regime MUST fail (the tail is single-consumer). | +| BODY-25 | MUST | Request/response body and body-logging lifecycle | A read from the response delegate that returns zero bytes for a positive requested count MUST be treated as a stream-contract violation (error), never as end-of-stream. End-of-stream MUST be signaled only by the explicit end-of-stream sentinel. This prevents silent truncation of the captured body. | +| BODY-26 | MUST | Request/response body and body-logging lifecycle | A failure during the response drain MUST NOT silently truncate. The wrapper MUST retain the bytes read before the failure and cache the error such that: reads (source) re-throw the cached error on every call; snapshot returns the partial bytes without throwing; and an exception-query accessor surfaces the cached error (or null) without triggering a drain. | +| BODY-27 | MUST | Request/response body and body-logging lifecycle | The response-logging wrapper MUST close the delegate (and, by ownership, its source) at most once across all close paths — the wrapper's own close and the one-shot tail stream's close MUST route through a single shared close-once guard — because some transport streams throw on a double-close. If the delegate's close throws, it MUST still be marked closed (the failing close propagates once, but no second close is attempted). | +| BODY-28 | MUST | Request/response body and body-logging lifecycle | On the fits-cap path, closing the delegate as part of a successful capture is best-effort: a close failure after a successful full capture MUST NOT be reported as a drain error and MUST NOT prevent the captured body from being served. The captured in-memory buffer MUST survive the wrapper's close (it holds only memory, no transport resource) so post-mortem snapshot logging still works after close. | +| BODY-29 | SHOULD | Request/response body and body-logging lifecycle | The response-logging wrapper's reported content length SHOULD be the captured size only when the body was fully captured within the cap; otherwise it SHOULD report the delegate's declared length (the true length), since the in-memory capture is only a bounded prefix. | +| BODY-30 | MUST | Request/response body and body-logging lifecycle | Turning an error response into an exception MUST buffer at most a fixed cap of the error body (1 MiB) into memory and re-serve it as a replayable body, dropping bytes beyond the cap. The buffered copy MUST be readable independently and repeatably (decode it, then snapshot it) after the original transport connection is released. Buffering MUST occur inside the original body's close-guaranteeing scope so a provider/buffer-allocation failure still closes the original body rather than leaking the connection. A response with no body MUST be returned unchanged. | +| BODY-31 | MUST | Request/response body and body-logging lifecycle | Error-to-exception mapping MUST apply only to error statuses (4xx/5xx). A non-error, non-success response (e.g. 304 Not Modified, or a 3xx that no redirect step followed) MUST be returned with its body intact rather than consumed and raised. | +| BODY-32 | MUST | Request/response body and body-logging lifecycle | Byte-capped snapshot/preview operations MUST reject a negative cap, MUST silently clamp the cap to the platform's maximum single-array size, and MUST return whatever bytes are available up to the (clamped) cap without requiring that exactly that many bytes exist. A snapshot operation with no explicit cap MUST fail loudly when the captured size exceeds the platform's maximum single-array size (rather than allocate an impossible array). | +| BODY-33 | SHOULD | Request/response body and body-logging lifecycle | An exception-side error-body preview SHOULD be non-consuming: it reads from a fresh peek view of the body's source and MUST NOT advance the primary read path, returning null when there is no body and an empty result when the source is exhausted. | +| BODY-34 | MUST | Request/response body and body-logging lifecycle | Body logging (the tee-capture on the request side and the drain-on-first-access on the response side) MUST be engaged only when body-level logging is enabled, and the in-memory capture on both sides MUST be bounded by one shared preview-size configuration. The consumer MUST still receive every byte of an over-preview body; only the logged preview and the logged size fields are bounded by the cap. | +| BODY-35 | MUST | Request/response body and body-logging lifecycle | A body's declared content length is an advisory contract: it MUST report the exact number of bytes the write will produce when known, and MUST report the 'unknown' sentinel (-1) otherwise. Ports MUST NOT assume a known content length is always present. | +| BODY-36 | MAY | Request/response body and body-logging lifecycle | A file-backed request body MAY expose a read-only memory-mapped view of its byte range for local computation (e.g. hashing/signing) without copying bytes onto the heap, but MUST reject mapping a range larger than a single addressable buffer and MUST document that the mapping's lifetime and any OS-level file locking are tied to the mapped view, not to a channel. | +| BODY-37 | MUST | Request/response body and body-logging lifecycle | A tee/mirror sink used for request-body logging MUST NOT expose a direct writable buffer handle that bypasses the primary sink; any such accessor MUST fail loudly. Bytes written straight into the tap buffer would never reach the transport, silently truncating the wire body, so every write MUST flow through a path that reaches both the primary sink and the tap. | +| CTX-1 | MUST | Execution context model (context promotion chain + ContextStore backstop) | The model MUST provide three context flavors forming a one-way promotion chain whose stages mirror the call lifecycle: a dispatch stage (before any request is built), a request stage (an outgoing request has been assembled), and an exchange stage (a response has arrived). Promotion advances dispatch -> request -> exchange only; there is no reverse promotion and the exchange stage is terminal (exposes no promotion API). | +| CTX-2 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Each promotion MUST be additive and non-mutating: it produces a NEW context instance and never modifies the source. It carries forward, unchanged, the fields the source already has — the same InstrumentationContext reference and the same callKey (and, for the request->exchange promotion, additionally the same request and operationName) — and adds exactly the one new artifact for that stage: the outgoing request when promoting dispatch->request, the response when promoting request->exchange. operationName is introduced at the request stage as an argument to the dispatch->request promotion; the dispatch context has no operationName field, so it is not 'carried forward' from the dispatch source. | +| CTX-3 | MUST | Execution context model (context promotion chain + ContextStore backstop) | The whole chain MUST share ONE call key: a promotion carries the source's callKey forward verbatim, so dispatch, request, and exchange contexts of the same call register under the identical store slot. | +| CTX-4 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Each call's store key MUST be unique per call and MUST NOT be derived from the trace identifier — or the trace+span pair — alone, so that two concurrent calls sharing a trace id (and even a span id) receive distinct keys and never overwrite or evict each other's live entry. The reference implementation derives the default key by appending a process-wide, monotonically increasing counter to a 'traceId:spanId' rendering (yielding 'traceId:spanId:n'); the exact format and the counter mechanism are a reference choice, and a port MAY key differently as long as the per-call-uniqueness invariant holds. | +| CTX-5 | MUST | Execution context model (context promotion chain + ContextStore backstop) | A context constructed directly (off-chain, not via promotion) without an explicit key MUST receive a fresh call-unique key using the same uniqueness guarantee as CTX-4. A consequence, given that the key participates in value-equality, is that two directly-constructed contexts with otherwise identical fields are NOT equal (their generated keys differ); callers who require value-equality MUST be able to pin an explicit shared key at construction. | +| CTX-6 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Default (unspecified-key) construction MUST mint globally distinct keys across the whole process and across all three context flavors, so that two off-chain constructions — even of different flavors that share identical trace/span ids — never collide on the same key. The reference implementation achieves this with a single process-wide monotone counter shared by all three flavors' default-key generation; a port MUST guarantee equivalent global distinctness however it generates keys. | +| CTX-7 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Contexts MUST be immutable and safe to share across threads without external synchronization. The ContextStore MUST be thread-safe such that contexts with distinct call keys can be registered, overwritten, and removed concurrently without external locking. | +| CTX-8 | MUST | Execution context model (context promotion chain + ContextStore backstop) | The store MUST support unconditional overwrite ('set': install-or-replace, never throwing) used by promotion, and MUST support a reject-on-duplicate insert ('put': install only if absent, failing the loser). Concurrent inserts of the same key via the reject-on-duplicate path MUST deterministically admit exactly one winner and fail all others with an error whose message identifies the key. (Promotion uses only 'set'; 'put' is a separate strict-register affordance.) | +| CTX-9 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Closing a context MUST evict the chain's store entry CONDITIONALLY ON REFERENCE IDENTITY: it removes the slot only when the currently registered occupant IS the closing context (same reference). It MUST NOT match by value equality. Removing a non-existent or already-replaced slot MUST be a well-defined no-op, not an error. | +| CTX-10 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Only the context that currently occupies the shared slot (the terminal / furthest-reached link) evicts the chain's entry on close. Closing an intermediate link (a dispatch that was promoted, or a request that was promoted) MUST be a no-op with respect to the store, because the slot now points at its successor. | +| CTX-11 | MUST | Execution context model (context promotion chain + ContextStore backstop) | The ContextStore MUST be bounded: it MUST enforce a maximum number of tracked entries and, after each insert, drain back down to at or below that cap. This bound is a backstop so that a caller who fails to close a context on an exception path leaks at most the cap's worth of entries rather than growing without limit. | +| CTX-12 | SHOULD | Execution context model (context promotion chain + ContextStore backstop) | The cap-draining strategy SHOULD be implemented as a post-insert drain LOOP (drain until at or under the cap) rather than a single check-then-evict-then-insert. Under concurrent insert bursts the loop converges the map back to the bound instead of overshooting and sitting above the cap. | +| CTX-13 | MAY | Execution context model (context promotion chain + ContextStore backstop) | Eviction victim selection under cap pressure is arbitrary: the store provides NO ordering (no insertion-order / FIFO / LRU) and NO guarantee that any particular entry survives an insert that trips the cap — INCLUDING the entry that was just inserted, which can itself be the drain's victim. The only retention guarantee is that after inserts quiesce the live set is at or below the cap (and, in isolation, that up to the cap of recently-inserted entries remain). A port MAY choose a smarter (e.g. oldest-first) victim policy, but a port MUST NOT rely on any specific entry — the most-recently inserted included — surviving. | +| CTX-14 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Each context MUST carry a correlation/instrumentation metadata bundle exposing at minimum: a trace id, a span id, trace flags, trace state, a trace-id encoding flavor, validity and remoteness flags, an active span, and a per-operation tracer factory. This bundle is what lets logs, metrics, and spans across the dispatch/request/exchange phases correlate into one logical trace. | +| CTX-15 | MUST | Execution context model (context promotion chain + ContextStore backstop) | A disabled-tracing / no-op instrumentation context MUST be available as the default and MUST expose reserved 'invalid' sentinel identifiers (an all-zero trace id, all-zero span id, zero trace flags, empty trace state) with isValid=false and isRemote=false, and a no-op span and no-op tracer factory. Because such a context shares constant identifiers across every untraced call, the call-key derivation (CTX-4) MUST remain correct (call-unique) even when every field of this bundle is identical across calls. | +| CTX-16 | SHOULD | Execution context model (context promotion chain + ContextStore backstop) | A context SHOULD carry an optional operation name (a schema-defined operation id such as 'GetUser', or absent for a raw request with no operation). When present it MUST be carried forward unchanged across every promotion. The operation name MUST be advisory only — it is exposed to the tracing seam to label the operation and MUST NOT influence the request, the dispatch decision, or the store key. | +| CTX-17 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Registration in the store MUST happen at promotion time, not at head-context construction: constructing the initial dispatch context MUST NOT auto-register it. The first store entry for a chain is installed by the first promotion; consequently a dispatch context that is never promoted leaves no store entry and its close is a harmless no-op. | +| CTX-18 | MUST | Execution context model (context promotion chain + ContextStore backstop) | Looking up an unknown key MUST return an explicit 'absent' result (null/None/empty), never throw. Removing an unknown or already-removed key MUST be a no-op, never throw. These make double-close and cleanup-path closes well-defined. | +| CTX-19 | MUST | Execution context model (context promotion chain + ContextStore backstop) | A registered context MUST keep its full Request+Response object graph reachable for as long as it stays in the store. Under this store's design the registry is treated as the sole strong reference on the live path, so reimplementations MUST NOT hold contexts by weak/soft references (that would let an in-flight context be collected mid-call) and MUST treat the bounded cap (CTX-11) — not garbage collection — as the leak backstop. | +| CTX-20 | SHOULD | Execution context model (context promotion chain + ContextStore backstop) | The per-operation tracer factory carried on the instrumentation bundle SHOULD default to a no-op that emits nothing, so untraced call sites pay zero tracing cost. Its factory method MUST be safe to invoke concurrently from multiple threads, because operation starts are not serialized. | +| PIPE-1 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Steps MUST execute in a single fixed total order derived from their stage assignment: a step in a lower-ordered stage runs before (wraps) a step in a higher-ordered stage on the inbound path, and correspondingly observes the response later on the outbound path. This CROSS-stage order is deterministic and independent of the order in which steps were added to the builder; the order of multiple steps WITHIN one non-pillar stage instead follows insertion (see PIPE-7). | +| PIPE-2 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The runtime MUST preserve the pillar precedence chain REDIRECT then RETRY then AUTH then LOGGING then SERDE (outer to inner), plus an outermost pre-redirect slot that runs OUTSIDE both the redirect and retry loops and a terminal SEND transport hop that runs innermost. A step's placement relative to these boundaries determines whether it sees intermediate (per-hop / per-attempt) responses or only the single terminal response. (SERDE is a reserved slot in the ordering with no shipped behavior yet.) | +| PIPE-3 | SHOULD | Stage-based execution pipeline runtime (http.pipeline) | The stage list SHOULD interleave user-extensible (non-pillar) slots around each pillar (a 'pre' slot before and a 'post' slot after) so callers can position steps at a precise point relative to a pillar without editing pillar internals. Numeric order keys SHOULD be sparse to allow inserting new stages later without renumbering existing ones. | +| PIPE-4 | MUST | Stage-based execution pipeline runtime (http.pipeline) | A pillar stage MUST admit at most one step. The configurable pillars are REDIRECT, RETRY, AUTH, LOGGING, and SERDE. (The terminal SEND slot is also flagged as a singleton but is the transport hop itself, not a configurable step slot — see PIPE-8.) | +| PIPE-5 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Installing a DISTINCT second step onto an already-occupied pillar — via append, prepend, insert-after/insert-before, or seeding/flattening from an existing pipeline (bulk reload) — MUST fail fast with an error rather than silently overwriting or dropping the existing step. The error SHOULD name both step types and point the caller at the replace-in-place path. (Replace itself cannot trigger this collision: it swaps a single occupant within its own stage 1:1; a cross-stage replace fails with a distinct cross-stage error instead.) | +| PIPE-6 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Re-installing the SAME step onto its pillar MUST be idempotent (no error, no duplication). Reference identity, not value equality, distinguishes 'same' from 'distinct'. | +| PIPE-7 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Non-pillar stages MUST hold an ordered sequence of steps: append adds to the tail (runs after steps already there), prepend adds to the head (runs before). The relative order of steps within a stage MUST be preserved through build and through any re-bucketing edit. | +| PIPE-8 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The terminal SEND stage MUST be reserved for the transport hop and MUST NOT hold a user step; flattening the pipeline MUST skip it, and any attempt to install a step at SEND MUST be rejected. | +| PIPE-9 | MUST | Stage-based execution pipeline runtime (http.pipeline) | An empty pipeline (no steps) MUST dispatch the request directly to the terminal transport, threading the caller's per-call options, and return/complete with the transport's result. It SHOULD do so without allocating per-call cursor state. | +| PIPE-10 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The built runtime MUST be immutable after construction (fixed ordered step collection + fixed transport reference), and each send/sendAsync MUST allocate its own per-call cursor so concurrent calls share no mutable pipeline state. | +| PIPE-11 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Steps MUST be safe to invoke from multiple threads concurrently, because a single step is shared across all calls. Per-request mutable state MUST live in the per-call cursor (carried and forked by next), never on the step. | +| PIPE-12 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Each step MUST be bidirectional: it receives the inbound request, MAY call next to invoke the rest of the chain, and MAY inspect or substitute the outbound response before returning it. A step MAY short-circuit by returning a synthetic response WITHOUT calling next. | +| PIPE-13 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Invoking next MUST advance a monotonic cursor to the next step and invoke it; when the cursor is exhausted, next MUST dispatch the current in-flight request to the terminal transport, threading the caller's per-call options into the transport's per-call execute overload. The cursor MUST only move forward within a single (un-forked) drive. | +| PIPE-14 | MUST | Stage-based execution pipeline runtime (http.pipeline) | When a step advances the chain with a substituted request, that substitution MUST propagate to every downstream step and to the terminal transport dispatch (it 'sticks'), replacing the original request for the remainder of that drive. | +| PIPE-15 | MUST | Stage-based execution pipeline runtime (http.pipeline) | A step that drives the downstream chain MORE THAN ONCE (retry re-attempting, redirect following a hop, auth retrying after a challenge) MUST fork a fresh cursor for each re-drive (the copy() operation) rather than reusing the same next handle. Reusing the same handle resumes past the already-visited steps and MUST be treated as a defect. A port MUST provide an equivalent fork primitive and its wrapping pillar steps MUST use it. | +| PIPE-16 | MUST | Stage-based execution pipeline runtime (http.pipeline) | A forked cursor MUST resume from the SAME position as its parent (re-running the entire downstream tail from that point), carry the current in-flight request, and share the immutable per-call options; forks MUST advance independently of one another. | +| PIPE-17 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The caller's per-call options MUST be carried unchanged for the entire call, including across every re-drive fork, MUST be readable by any step, and MUST be threaded into the terminal transport dispatch. Options MUST be immutable/shared, not copied-and-diverged per fork. | +| PIPE-18 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The surgical insert-after / insert-before edit MUST place a step immediately after/before the FIRST existing step that is an instance of a given anchor type. The inserted step MUST declare the same stage as the matched anchor; a cross-stage insert MUST be rejected with an error rather than silently relocating the step to wherever its own stage falls. | +| PIPE-19 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The surgical replace edit MUST swap the FIRST existing instance of the anchor type with the supplied step, which MUST declare the same stage as the replaced step; a cross-stage replacement MUST be rejected. | +| PIPE-20 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The surgical remove edit MUST delete EVERY step that is an instance of the given type while preserving the relative order of the remaining steps, and MUST be a no-op when no instance exists. | +| PIPE-21 | MUST | Stage-based execution pipeline runtime (http.pipeline) | An insert-relative or replace edit whose anchor type has no instance in the pipeline MUST fail with an error identifying the missing type, rather than silently no-op'ing. | +| PIPE-22 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Every mutation that re-buckets the whole step collection by stage (insert, replace, remove, reload) MUST re-derive the flattened order deterministically from stage assignment, so the observable ordering after an edit is identical to building the same set of steps from scratch. | +| PIPE-23 | MUST | Stage-based execution pipeline runtime (http.pipeline) | A bulk reload/rebuild of the step collection MUST be all-or-nothing: if it encounters a pillar collision (a distinct second step for an occupied pillar), it MUST leave the existing collection completely unchanged rather than committing a partially-rebuilt state. | +| PIPE-24 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The standard-resilience preset MUST install its pillar steps into EMPTY slots only, validating up front that none of the target pillars is already occupied and rejecting the whole call (installing nothing) if any is. It MUST NOT overlay onto or overwrite already-configured pillars. | +| PIPE-25 | MUST | Stage-based execution pipeline runtime (http.pipeline) | build() MUST produce the ordered step sequence by flattening stages in declaration order (skipping SEND) into an immutable runtime, and the runtime MUST expose a read-only, ordered view of its steps for inspection. | +| PIPE-26 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The pipeline runtime MUST itself implement the transport SPI, delegating its execute/executeAsync to its own send/sendAsync (with and without per-call options), so a fully configured pipeline can be used anywhere a transport is expected (e.g. backing a paginator) and options survive that indirection. | +| PIPE-27 | MUST | Stage-based execution pipeline runtime (http.pipeline) | Closing the pipeline MUST be a no-op with respect to the underlying transport: the pipeline never owns its transport and MUST NOT close it. A port MUST NOT let wrapping a pipeline in a resource-management scope create the impression that transport resources were released. | +| PIPE-28 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The async runtime MUST reuse the identical stage identities and staging policy as the sync runtime (same ordered stage list, same pillar exclusivity, same surgical-edit semantics), so a given concern occupies the same ordered slot in both runtimes. The two runtimes MUST NOT each re-derive ordering independently. | +| PIPE-29 | MUST | Stage-based execution pipeline runtime (http.pipeline) | An async step MUST NOT throw synchronously to signal a transport or async failure — it MUST return a future that completes exceptionally. It MAY throw synchronously only for caller-bug argument-validation errors. The runtime MUST present a uniform async error model regardless. | +| PIPE-30 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The async runtime MUST defensively normalize ANY synchronous exception thrown by a step's async entry point (or by the empty-pipeline transport dispatch) into an exceptionally-completed future, so one step's mistake cannot break the pipeline's async contract. Fatal/unrecoverable errors (e.g. out-of-memory / stack overflow) MUST propagate synchronously and MUST NOT be swallowed. | +| PIPE-31 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The async terminal response-mapping operator MUST, on success, apply the handler and then close the response (idempotent double-close tolerated); on failure it MUST unwrap async wrapper exceptions to the original cause before failing the returned future, and MUST close any response that accompanies a failure to avoid leaking the body. | +| PIPE-32 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The async standard pipeline MUST NOT follow HTTP redirects at the pipeline layer (there is no async redirect pillar step); a 3xx MUST surface to the caller verbatim unless redirect following is enabled on the transport itself. A port MUST document this asymmetry with the sync standard pipeline (which does install a redirect step). | +| PIPE-33 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The sync-to-async bridge MUST require a caller-supplied executor (no default), MUST run the wrapped synchronous pipeline as a single opaque unit on that executor (the whole sync pipeline executes end-to-end as one blocking call, so its own steps stay synchronous on the worker/dispatch thread and do NOT gain per-step concurrency), and MUST thread the caller's per-call options into the wrapped synchronous send. Cancelling the returned future with interruption MUST interrupt the worker running the in-flight send; cancelling without interruption MUST complete as cancelled without interrupting the worker. | +| PIPE-34 | MUST | Stage-based execution pipeline runtime (http.pipeline) | The async-to-sync bridge MUST block the calling thread on the async result for each call while preserving per-call options, and MUST honour thread interruption: on interrupt it restores the interrupt flag, cancels the in-flight future, and surfaces an interrupted-I/O error. | +| PIPE-35 | SHOULD | Stage-based execution pipeline runtime (http.pipeline) | The builder SHOULD provide two unambiguous ways to seed from an existing pipeline: FLATTEN (copy the existing pipeline's steps and transport into the new builder so they run in the same loops) versus NEST (treat the existing pipeline as an opaque transport so the new builder's steps run once, OUTSIDE the nested pipeline's redirect/retry/auth loops). A port MUST make the flatten-vs-nest choice explicit rather than accidental. | +| PIPE-36 | SHOULD | Stage-based execution pipeline runtime (http.pipeline) | The shipped pillar step families (redirect, retry, auth, instrumentation) SHOULD lock their stage assignment so a subclass/extension cannot relocate itself out of its pillar. Custom steps at a pillar stage that re-drive the chain MUST still honour the fork-per-re-drive contract (PIPE-15). | +| PIPE-37 | MUST | Stage-based execution pipeline runtime (http.pipeline) | A step whose correctness depends on observing only the SINGLE terminal response (e.g. mapping a non-successful status to a typed error) MUST be placed at the outermost pre-redirect slot so it runs outside both the redirect and retry loops, and on a non-error status it MUST return the response untouched (body not read, consumed, or closed). | +| PIPE-38 | MUST | Stage-based execution pipeline runtime (http.pipeline) | When adding a batch of steps, append-all MUST preserve the batch's iteration order within each stage, while prepend-all (each element prepended individually) MUST result in the REVERSED batch order within each stage. A port MUST document this asymmetry so callers get predictable ordering. | +| PIPE-39 | SHOULD | Stage-based execution pipeline runtime (http.pipeline) | The runtime SHOULD offer convenience constructors for the two most common shapes: a step-less pipeline that forwards directly to a transport, and a standard pipeline that installs the default resilience pillars over a transport (sync: redirect+retry+instrumentation; async: retry+instrumentation with a caller-supplied scheduler for non-blocking backoff). | +| PIPE-40 | MUST | Stage-based execution pipeline runtime (http.pipeline) | A wrapping step that re-drives the downstream chain (redirect following a hop, retry re-attempting, auth replaying after a challenge) MUST release each superseded intermediate response — closing its body before issuing the next drive — and MUST NOT close the response it ultimately hands back to the caller (close-responsibility passes outward to the caller or the next outer step). On paths that abandon a re-drive (redirect cycle detected, non-replayable body, hop/attempt budget exhausted) the in-flight response MUST be returned unclosed. | +| RECOV-1 | MUST | Recovery-chain pipeline primitives | The response-side outcome MUST be a closed sum type with exactly two variants: a success variant carrying an HTTP response, and a failure variant carrying a throwable/error. The two variants are mutually exclusive and jointly exhaustive; an outcome is always exactly one of them. Standard accessors (is-success / is-failure, response-or-null, error-or-null) and a fold that applies exactly one of two branch functions MUST be derivable from this shape, and the fold MUST invoke the selected branch at most once per call. | +| RECOV-2 | MUST | Recovery-chain pipeline primitives | The unified orchestrator MUST catch EVERY throwable raised by (a) any request-chain step and (b) the transport invocation, convert it into a Failure outcome, and thread it through the response recovery chain. No throwable from the pre-request phase or the transport may bypass the recovery hooks. This is the defining invariant of the subsystem: a before-request throw MUST NOT skip after-error handling. | +| RECOV-3 | MUST | Recovery-chain pipeline primitives | The request recovery chain MUST apply its ordered steps as a sequential left-to-right fold: the output request of step N is the input of step N+1. An empty chain MUST return the input request unchanged (identity/no-op). If a step throws, the chain MUST NOT invoke the remaining steps and MUST propagate the throwable to its caller (which the orchestrator then converts per RECOV-2). | +| RECOV-4 | MUST | Recovery-chain pipeline primitives | In the response recovery chain, response steps (response→response) MUST run ONLY when the current outcome is a Success. When the current outcome is a Failure, the entire response-step phase MUST be skipped (the failure passes through untouched to the recovery phase). | +| RECOV-5 | MUST | Recovery-chain pipeline primitives | Recovery steps MUST be applied to EVERY outcome — both successes and failures — sequentially, and MUST always run regardless of how many response steps executed. Recovery steps MUST observe the terminal outcome, including a failure that a response step just produced by throwing. | +| RECOV-6 | MUST | Recovery-chain pipeline primitives | The fold order MUST be: all response steps first (on the success path), then all recovery steps, in declared order within each group. A recovery step MUST NOT run before the response-step phase has produced the outcome it will observe. | +| RECOV-7 | MUST | Recovery-chain pipeline primitives | If a response step throws, its throwable MUST be converted into a Failure outcome and fed to the subsequent recovery steps — never propagated out of the response chain. Failures originating inside the response phase MUST participate in the same recovery pathway as transport failures. | +| RECOV-8 | MUST | Recovery-chain pipeline primitives | If a recovery step itself throws, its throwable MUST be wrapped into a Failure and fed to the NEXT recovery step. A throwing recovery step MUST NOT bypass or abort the remaining recovery steps, and the response recovery chain's apply operation MUST NOT throw under any input. | +| RECOV-9 | SHOULD | Recovery-chain pipeline primitives | Recovery steps SHOULD NOT throw; they SHOULD surface errors explicitly by returning a Failure outcome. Throwing is a supported-but-discouraged fallback (see RECOV-8) that cedes control of the wrapped error to the chain's defensive catch. | +| RECOV-10 | MUST | Recovery-chain pipeline primitives | The unified orchestrator's dispatch operation MUST unwrap the final outcome as follows: on Success it returns the contained response; on Failure it rethrows the contained throwable UNCHANGED (no wrapping, no substitution). Any typed-exception surfacing must be performed by a recovery step constructing the error and returning a Failure. | +| RECOV-11 | MUST | Recovery-chain pipeline primitives | When a throwable that represents thread interruption/cancellation is wrapped into a Failure, the wrapping helper MUST first re-assert the interrupt/cancellation signal on the current execution context before returning the Failure, so that code later blocked on the surfaced outcome still observes the cancellation. Portable intent: converting a cancellation exception into a data value must not swallow the cancellation signal. In the reference implementation the shared wrapper restores the JVM interrupt flag specifically for InterruptedException; a port preserves whatever its cancellation primitive is. | +| RECOV-12 | MUST | Recovery-chain pipeline primitives | When a response step or recovery step THROWS while a Success response is in hand, the pipeline MUST close/release that in-hand response (its open transport connection and body stream) before wrapping the throwable into a Failure, and MUST attach any error raised while closing as a suppressed/secondary error so it never masks the primary throwable. The response MUST be released exactly once on this throw path. (Applies to any runtime with explicit resource release for streamed bodies; a GC-only body model must still ensure single-release where connections are pooled.) | +| RECOV-13 | MUST | Recovery-chain pipeline primitives | When a step is handed a Success and deliberately RETURNS a different outcome (a Success→Failure transform such as status→typed-exception mapping, or a substitute Success), the pipeline MUST NOT auto-close the discarded original response. The step that performs that transform OWNS releasing the response it drops and MUST release it before returning the replacement. (The failure→failure 'Replace' path carries no response, so nothing needs releasing there.) | +| RECOV-14 | MUST | Recovery-chain pipeline primitives | A chain's step lists MUST behave as immutable after construction. The response recovery chain MUST defensively copy BOTH its response-step list and its recovery-step list at construction, so later mutation of a caller-supplied list cannot alter chain behavior. (Reference-implementation asymmetry a porter must not assume away: the request recovery chain does NOT copy — it retains the caller-supplied read-only list reference directly, so its immutability depends on the caller not mutating that list; a port SHOULD copy there too.) Steps MUST be safe to invoke concurrently from multiple execution contexts; per-request state MUST live in the passed context or the value being transformed, never on the step instance. | +| RECOV-15 | MUST | Recovery-chain pipeline primitives | The status→typed-exception mapping response step MUST treat only HTTP status codes in the range 400..599 as errors: such a status MUST be mapped to the matching typed exception (which the pipeline then turns into a Failure per RECOV-7), and all other statuses (1xx/2xx/3xx) MUST be returned unchanged so the success chain continues. Because a transport returns a response for every completed exchange (including 4xx/5xx), an error status is a response until this step maps it. | +| RECOV-16 | MUST | Recovery-chain pipeline primitives | Before mapping an error-status response to a typed exception (both in the error-mapping response step and when a retry re-classifies a re-sent error response), the error body MUST be buffered into a bounded, replayable in-memory copy capped at a fixed maximum (1 MiB / MAX_BUFFERED_ERROR_BODY_BYTES) so that (a) the live transport connection is released promptly and (b) the error body remains readable on the resulting Failure. The cap MUST be a hard truncation: bytes beyond the cap are simply not read and are discarded with no marker, so a downstream consumer deserializing the buffered body must tolerate a structurally incomplete payload. The same bound MUST be shared across all error-body-buffering paths. | +| RECOV-17 | MUST | Recovery-chain pipeline primitives | Retry eligibility MUST be classified off a retryability CAPABILITY, not concrete exception types. Specifically: for an HTTP-response-bearing failure, the configured retryable-status allow-list is authoritative (it is consulted alone — the exception's own retryable flag is not AND-ed in — so it both widens and narrows), retry iff the failure's status is in that set; for a non-HTTP failure, retry iff it advertises retryability via the capability flag; a network-level failure (no response ever received) is always retryable via that flag; a Success is always a pass-through. | +| RECOV-18 | MUST | Recovery-chain pipeline primitives | Independently of classification, a request MUST pass a re-sendability gate before being retried: a body-less request is retry-safe only when its method is in the configured idempotent-method set; a body-bearing request is retry-safe only when its body is replayable. A body-less request whose method is non-idempotent (e.g. a bare POST) MUST NOT be retried even though there is no payload to resend; a non-replayable body MUST NOT be resent (resending would trip its consume-once guard and mask the original failure). | +| RECOV-19 | MUST | Recovery-chain pipeline primitives | Because a transport returns an error-status response rather than throwing, each RE-SENT attempt's response MUST be re-classified: if its status is an error status present in the retryable-status set, it MUST be re-mapped into a Failure (with its body buffered per RECOV-16) so the loop keeps evaluating the budget and can reach an eventual success (e.g. a 503,503,200 sequence must terminate on the 200). All other re-sent responses — including a non-retryable error status — pass through as Success. | +| RECOV-20 | MUST | Recovery-chain pipeline primitives | Retrying MUST be bounded by BOTH a maximum-attempts cap (counting the initial send as attempt 1) AND a total-timeout budget spanning the retry involvement (attempts and inter-attempt delays). A scheduled delay that would push cumulative elapsed time past the total-timeout MUST be suppressed and the last failure surfaced unchanged. A total-timeout of zero MUST mean 'unbounded' (deadline disabled). When retries are exhausted or disallowed, the terminal failure's throwable MUST be surfaced. (The total-timeout deadline is the recovery layer's addition; the stage-based retry step intentionally omits it.) | +| RECOV-21 | MUST | Recovery-chain pipeline primitives | The backoff delay for retry n MUST be computed as initialDelay * multiplier^(n-1), capped at maxDelay, then perturbed by symmetric jitter drawn uniformly from [delay*(1-jitter/2), delay*(1+jitter/2)] (jitter=0 → no perturbation; midpoint stays at delay), then clamped so that elapsed+delay does not exceed the total-timeout. The attempt index passed to backoff is 1-based where 1 is the delay before the first retry (i.e. after the initial send failed) and MUST be rejected if < 1. | +| RECOV-22 | MUST | Recovery-chain pipeline primitives | A parsed server pacing hint (Retry-After / X-RateLimit-Reset family) MUST take precedence over the computed exponential schedule for that single retry decision — it REPLACES, not augments, the exponential value — while STILL being clamped by the remaining total-timeout budget. The hint MUST NOT have additional symmetric jitter applied on top (Retry-After is respected as given; the X-RateLimit-Reset positive jitter is already applied inside the parser per RECOV-25). | +| RECOV-23 | MUST | Recovery-chain pipeline primitives | The pacing-header parser MUST be TOTAL — it MUST never throw for any header value. Malformed, negative, or out-of-range values MUST map to 'no hint' (null / absent), NOT to a zero delay, so the orchestrator falls back to its exponential schedule rather than retrying instantaneously against a server that asked it to slow down. A validly-parsed but past/elapsed absolute time (HTTP-date or epoch) MUST map to a zero delay (retry immediately). | +| RECOV-24 | MUST | Recovery-chain pipeline primitives | The pacing-header parser MUST recognize these forms and, when parsing a full header set, apply this precedence (first usable wins): Retry-After as numeric delta-seconds (integer and fractional, honored to sub-second resolution) tried before Retry-After as an HTTP-date; then a millisecond-delta variant (retry-after-ms); then the Microsoft/Azure millisecond-delta variant (x-ms-retry-after-ms); then X-RateLimit-Reset as a Unix-epoch-seconds absolute time. The numeric delta-seconds form MUST be screened by a strict decimal grammar BEFORE any float parse, so values like '30d' or hex-float forms are rejected (fall through to backoff) rather than mis-parsed. | +| RECOV-25 | SHOULD | Recovery-chain pipeline primitives | When honoring an X-RateLimit-Reset absolute reset time, the parser SHOULD add positive jitter (bounded to roughly 100%..120% of the computed delta) so that many clients released at the same reset instant do not stampede the server simultaneously. | +| RECOV-26 | MUST | Recovery-chain pipeline primitives | All duration arithmetic in backoff and pacing computation MUST be overflow-safe: server-supplied hints and computed deltas MUST be clamped to a sane ceiling (365 days) before any nanosecond conversion, and nanosecond conversion MUST saturate rather than throw on out-of-range durations, so a hostile or buggy server value can never surface an arithmetic overflow mid-retry. | +| RECOV-27 | MUST | Recovery-chain pipeline primitives | The inter-attempt wait MUST be cancellable and MUST NOT permanently block/pin a scheduling worker while idle. On cancellation/interruption during the wait, the implementation MUST (a) re-assert the cancellation signal, (b) cancel the pending scheduled timer, and (c) surface an interrupted-I/O failure through the outcome that ABORTS the retry loop rather than continuing. Portable intent: retries must honor cooperative cancellation and must not busy-wait or hold a thread hostage during the delay (the reference implementation uses a scheduler + interruptible wait, not a plain sleep; a port uses its runtime's cancellable timer/sleep with the same abort semantics). | +| RECOV-28 | MUST | Recovery-chain pipeline primitives | A retry engine instance MUST be stateless across calls with respect to attempt bookkeeping: the per-call attempt count and start instant MUST be allocated fresh for each top-level invocation and threaded as local state, so concurrent invocations cannot clobber each other's budget and a reused/re-invoked instance always starts from a clean attempt budget. | +| RECOV-29 | MUST | Recovery-chain pipeline primitives | A malformed or unparsable server pacing header MUST NEVER mask or replace the real upstream failure: if extracting a pacing hint fails, the engine MUST fall back to its exponential schedule while preserving the original upstream throwable as the error to surface if retries are ultimately exhausted. | +| RECOV-30 | SHOULD | Recovery-chain pipeline primitives | The recovery-aware retry stack and the separate stage-based dispatch retry step SHOULD share ONE backoff calculator and ONE pacing-header parser and the SAME default schedule (same base/max delay, multiplier, jitter, retryable-status policy, and total send budget) so the two stacks cannot drift apart. A port that offers more than one retry entry point SHOULD single-source the retry math likewise. The recovery-aware stack MAY additionally enforce a total-timeout deadline that the stage-based step omits. | +| RECOV-31 | MAY | Recovery-chain pipeline primitives | The retry engine MAY stamp a per-attempt request header carrying the 1-based attempt ordinal on each dispatch it performs. When enabled, the header MUST be written on a fresh per-attempt COPY of the request (never mutating the captured template) and MUST preserve any idempotency key or other headers unchanged. Ordinal 1 (the original send) is stamped only when the engine itself performs the initial send via its direct-dispatch entry point; when the engine runs as a recovery hook in a chain the initial send was performed upstream and carries no attempt header, so only the retries the engine dispatches (ordinal 2, 3, ...) are stamped. When disabled (the default), the engine MUST take a zero-allocation no-op path that returns the original request unchanged. | +| RECOV-32 | MUST | Recovery-chain pipeline primitives | The idempotency-key injection step MUST add its configured key header only for requests whose method is in the configured method set (default: the non-idempotent write methods POST/PUT/PATCH) and MUST pass other methods through untouched. When configured to respect existing headers (the default), a request that already carries the header MUST be left unchanged and the key strategy MUST NOT be invoked; otherwise the strategy result overwrites any existing value. The key strategy MUST be invoked at most once per applicable request. | +| RECOV-33 | MUST | Recovery-chain pipeline primitives | The client-identity step MUST compose its configured tokens into a single space-separated header value with mode-specific reconciliation: in Append mode (default) it appends its token line after the FIRST existing header value (preserving all other pre-existing values) or sets it as the sole value if the header is absent; in Replace mode it overwrites all existing values. If the token list is empty or joins to a blank/whitespace-only line, the step MUST be a no-op and MUST NOT emit a blank or whitespace-only header. (In Append mode, an empty first existing value is treated as absent so no leading space is emitted.) | +| RECOV-34 | MUST | Recovery-chain pipeline primitives | Retry configuration MUST validate its inputs at construction and reject invalid values: durations must be non-negative and representable in nanoseconds (~292-year ceiling); the delay multiplier must be >= 1.0; maximum attempts must be >= 1 (1 disables retries); and the jitter fraction must lie in [0.0, 1.0]. Collection-valued settings (retryable statuses, retryable methods) MUST be defensively copied at build time so later caller mutation cannot alter behavior. | +| RETRY-1 | MUST | Retry and resilience | The retryable-status classifier MUST be single-sourced (one definition the SDK consults) and MUST treat exactly these codes as retryable: 408 (Request Timeout), 429 (Too Many Requests), and all of 500–599 EXCEPT 501 (Not Implemented) and 505 (HTTP Version Not Supported). This classifier is the single definition the response-carrying exception flag and the stage stack's default predicate derive from; the recovery stack layers its own configurable status allow-list on top (see RETRY-37) rather than a second copy of the classifier. | +| RETRY-2 | MUST | Retry and resilience | The set of retryable throwables MUST be defined in exactly one place: any throwable that IS, or has anywhere in its cause chain, an I/O error or a timeout error. The cause-chain walk MUST be iterative and MUST terminate on a self-referential/cyclic chain (track visited nodes by identity). | +| RETRY-3 | MUST | Retry and resilience | An exception that carries a received HTTP response MUST derive its own retryable flag from the single status classifier (RETRY-1) at construction time, not from a hardcoded per-subclass constant. | +| RETRY-4 | MUST | Retry and resilience | A transport-level failure that occurred before a complete response was received (connection refused, TLS/DNS failure, socket read timeout, peer reset) MUST be classified as a retryable condition unconditionally at the condition level (safety is still gated separately by re-sendability). | +| RETRY-5 | MUST | Retry and resilience | A request is re-sendable if and only if: it has no body AND its method is idempotent, OR it has a body AND that body is replayable. Both retry stacks MUST apply this identical rule. | +| RETRY-6 | MUST | Retry and resilience | The idempotent-method set MUST be single-sourced and equal to {GET, HEAD, OPTIONS, PUT, DELETE}. POST/PATCH are NOT idempotent and are re-sendable only via the replayable-body path. | +| RETRY-7 | MUST | Retry and resilience | When a request is not re-sendable, the retry logic MUST perform exactly one attempt and MUST NOT retry — even when the failure condition is retryable and even when there is no body to physically re-send (e.g. a bare non-idempotent POST). | +| RETRY-8 | MUST | Retry and resilience | Retry eligibility MUST require BOTH a retryable condition AND a re-sendable request; the two gates are independent and neither implies the other. | +| RETRY-9 | MUST | Retry and resilience | The unjittered exponential delay MUST be initialDelay × multiplier^(attempt−1), where attempt is 1-indexed (attempt 1 = the wait before the first retry), clamped to a maximum delay cap. | +| RETRY-10 | MUST | Retry and resilience | Symmetric jitter MUST draw the effective delay uniformly from [d×(1−j/2), d×(1+j/2)] with the midpoint at d. j=0 MUST return d unchanged; j MUST be constrained to [0,1] (1 ⇒ range [0, 2d]). A degenerate sub-nanosecond range MUST return the base delay rather than error, and a negative sample MUST be floored to zero. | +| RETRY-11 | MUST | Retry and resilience | Delay computation MUST be overflow-safe: pathological attempt counts, multipliers, or configured magnitudes MUST saturate to the cap rather than throw. The calculator MUST reject attempt < 1. | +| RETRY-12 | SHOULD | Retry and resilience | The default tuning constants SHOULD be: initial/base delay 200ms, multiplier 2.0, max delay cap 8s, jitter fraction 0.2, and a total budget of 3 sends (initial + 2 retries). | +| RETRY-13 | MUST | Retry and resilience | Both retry stacks MUST compute their backoff via the one shared calculator using the one shared set of constants (multiplier, jitter, base, cap); the stacks MUST NOT carry independent backoff formulas or duplicated constants. | +| RETRY-14 | MUST | Retry and resilience | The two stacks' attempt budgets MUST denote the same number of total wire sends under equivalent defaults: the recovery stack's maxAttempts (total, default 3) MUST equal the stage stack's maxRetries (default 2) + 1 initial send. | +| RETRY-15 | MUST | Retry and resilience | The pacing-header parser MUST recognize: Retry-After as delta-seconds (integer AND fractional, honored to nanosecond resolution); Retry-After as an RFC 1123 HTTP-date (tolerant of an informational weekday and single-digit day-of-month); retry-after-ms and x-ms-retry-after-ms as integer milliseconds; and X-RateLimit-Reset as a Unix epoch in seconds whose delta is positively jittered to [100%,120%]. | +| RETRY-16 | MUST | Retry and resilience | The pacing-header parser MUST be total (never throw for any input). Malformed, negative, or out-of-range values MUST map to 'no hint' (null), NOT to a zero delay, so the caller falls back to its exponential schedule rather than hammering a server that asked it to slow down. | +| RETRY-17 | MUST | Retry and resilience | An HTTP-date or Unix-epoch pacing value whose moment has already passed MUST yield a zero delay (retry immediately), distinct from an unparseable value (which yields no hint). | +| RETRY-18 | MUST | Retry and resilience | Any computed pacing delta MUST be clamped to a finite sane ceiling (365 days) before nanosecond conversion so a far-future date/epoch cannot overflow the duration arithmetic. | +| RETRY-19 | MUST | Retry and resilience | Numeric Retry-After parsing MUST be screened by a strict decimal grammar (digits with an optional single fractional part) BEFORE any float parse, rejecting type-suffixed, hex-float, NaN, and Infinity forms (e.g. '30d', '0x1p4'). | +| RETRY-20 | MUST | Retry and resilience | When a server pacing hint is present it MUST override (replace, not augment) the exponential schedule for that single decision. A literal Retry-After hint MUST NOT receive additional symmetric jitter; where a total-timeout deadline applies the hint MUST still be clamped against it (the recovery stack routes the hint through the calculator's deadline clamp; the stage stack, which has no deadline, returns the parsed hint verbatim). | +| RETRY-21 | MUST | Retry and resilience | Pacing-header resolution MUST honor a defined precedence and return the first parseable value. The recovery stack scans the whole header map with fixed precedence (Retry-After [numeric then date] → retry-after-ms → x-ms-retry-after-ms → X-RateLimit-Reset); the stage stack walks a caller-configurable ordered header list, parsing each by its name's semantics. | +| RETRY-22 | MUST | Retry and resilience | A failure while parsing a pacing header MUST NOT mask the real upstream failure: the loop falls back to its exponential schedule and the original upstream throwable remains the surfaced error. The recovery stack defensively swallows any RuntimeException from hint parsing; the stage stack relies on the parser being total (RETRY-16) so no parse error arises. | +| RETRY-23 | MUST | Retry and resilience | Thread interruption / cancellation MUST never be treated as a retryable failure. On interrupt during a blocking backoff wait the implementation MUST restore the interrupt flag, cancel any externally-scheduled wake, abort the retry loop, and surface an interrupted-I/O error (with the original interrupt attached). In the async stack, a downstream interrupt surfaced as an interrupted-I/O error is treated as terminal cancellation (interrupt flag restored) rather than retried. | +| RETRY-24 | MUST | Retry and resilience | A read-timeout that is represented as a subtype of the interrupted-I/O error MUST NOT be mistaken for cancellation: it remains a retryable condition and flows through normal classification. | +| RETRY-25 | MUST | Retry and resilience | Non-recoverable JVM errors (out-of-memory, stack overflow) MUST NOT be retried, classified as a retryable condition, or logged; they MUST be surfaced unchanged with no suppressed-trail attachment. In the stage-based sync and async stacks the error propagates immediately at the throw site; the shared predicate/delay invocation rethrows an error rather than wrapping it. (The recovery stack captures a transport-thrown error into its failure channel but still never classifies it retryable, never retries it, and surfaces it terminally — a mechanism a port unifying the stacks should note.) | +| RETRY-26 | MUST | Retry and resilience | The inter-attempt wait MUST be cancellable/interruptible and MUST NOT pin an execution carrier for its duration. Implementations differ by stack: the recovery stack schedules the wake on a shared scheduler and blocks on the resulting future (interruptible, Loom-friendly); the stage-based sync stack performs an interruptible sleep that unmounts a virtual-thread carrier; async implementations schedule the delay without blocking any thread. A naive uninterruptible sleep that cannot be cancelled is non-conforming. | +| RETRY-27 | MUST | Retry and resilience | The recovery stack MUST enforce an optional total-timeout budget with per-attempt deadline shrinking: before scheduling each attempt it aborts if the attempt cap is reached, if elapsed >= budget, or if elapsed + next-delay would exceed the budget; the computed delay is additionally clamped so it cannot overshoot the budget. A zero budget disables the deadline (unbounded). | +| RETRY-28 | MUST | Retry and resilience | The stage-based stack MUST NOT impose a total-timeout budget (its backoff view sets the budget to zero); a port that unifies the stacks MUST make the total-timeout budget an explicitly opt-in feature rather than an always-on one. | +| RETRY-29 | MAY | Retry and resilience | An opt-in server-driven override MAY let a response header force or suppress the retry classification: a truthy value forces a retry (even for a normally non-retryable status), a falsy value suppresses one (even for a normally retryable status); absent/unrecognized values, and the exception path, defer to the underlying classifier. The override MUST flip only classification and MUST remain subject to the attempt cap and the re-send-safety gate. | +| RETRY-30 | MUST | Retry and resilience | The asynchronous retry loop MUST be driven by an iterative trampoline: N retries MUST NOT build an N-deep chain of future continuations or N-deep stack frames. A completion that warrants another attempt hands control back to a single active pump via a re-arm flag rather than recursing. | +| RETRY-31 | MUST | Retry and resilience | Async backoff delays MUST be scheduled non-blockingly (no blocking sleep on the dispatching thread). A zero-length delay MUST complete inline and re-arm the active pump without adding a stack frame; a positive delay fires later on a scheduler and starts a fresh shallow pump. | +| RETRY-32 | MUST | Retry and resilience | If the caller has already completed or cancelled the returned async result, the driver MUST launch no further network attempts, and any response that arrives from an already-in-flight attempt MUST be closed rather than leaked. | +| RETRY-33 | MUST | Retry and resilience | Every terminal path of the async loop MUST complete the returned future (never leave it hanging). A throwing should-retry predicate, a throwing delay computation, a throwing log call, or a synchronous scheduler rejection MUST each complete the future exceptionally; any open retryable response MUST be closed before surfacing. | +| RETRY-34 | MUST | Retry and resilience | On terminal failure, every prior failed attempt's exception MUST be attached to the surfaced exception as suppressed, and the surfaced instance itself MUST be skipped so a transport that reuses one exception instance across attempts cannot trip a self-suppression error. On eventual success after retries, the prior-attempt trail MUST be discarded (not surfaced). NOTE: only the async stack currently implements the skip-self guard; the sync stack attaches the prior trail unconditionally and would raise IllegalArgumentException if the terminal instance also appears in the trail — a port MUST apply the guard to both. | +| RETRY-35 | MUST | Retry and resilience | A retryable response's body/connection MUST be released before the backoff wait so the socket/buffer is not pinned across the delay. The pacing delay MUST be computed from the still-open response first; if the retry decision or delay computation throws, the response MUST still be closed before the throwable propagates. (This is the stage stacks' mechanism; the recovery stack releases the connection by buffering the error body — RETRY-36.) | +| RETRY-36 | MUST | Retry and resilience | In the recovery stack, a re-sent response whose error status is in the configured retryable-status set MUST be re-mapped into a typed failure so the loop keeps evaluating the budget (a 503,503,200 sequence MUST reach the 200). The re-sent error body MUST be buffered into a bounded, replayable in-memory copy so the transport connection is released before the next attempt. | +| RETRY-37 | MUST | Retry and resilience | In the recovery stack, for a failure carrying a received response the configured retryable-status set MUST be authoritative — it can both widen (retry a status the built-in classifier would not) and narrow. For a no-response transport failure, classification MUST fall back to its always-retryable flag. | +| RETRY-38 | SHOULD | Retry and resilience | An optional per-attempt request header MAY stamp the 1-based attempt ordinal (1 for the original send, 2 for the first retry, …) on a fresh per-attempt copy of the request. When enabled it MUST NOT mutate the captured template and MUST preserve any existing idempotency key; when disabled (the default) it MUST allocate nothing and send the request unchanged. | +| RETRY-39 | MUST | Retry and resilience | The stage stack's delay resolution MUST follow the precedence: caller delay-override provider → server pacing headers (response path only) → fixed delay → exponential backoff. The exception path skips the header step (there is no response). | +| RETRY-40 | SHOULD | Retry and resilience | A misbehaving user delay-override that throws SHOULD be non-fatal: log and fall back to default delay resolution. A misbehaving should-retry predicate that throws SHOULD abort the call (surface as a well-typed illegal-state error), with JVM-fatal errors rethrown unchanged in both cases. | +| RETRY-41 | MUST | Retry and resilience | The stage stack MUST resolve the effective retry count per call as: a present per-call override wins (validated non-negative), otherwise the configured value; a negative configured value MUST be clamped to the default (and the clamp logged); zero is a valid value meaning 'no retries'. | +| RETRY-42 | MUST | Retry and resilience | All retry policy/configuration components MUST be immutable and stateless after construction and safe to invoke concurrently; every piece of per-call mutable state (attempt count, start instant, suppressed trail, trampoline flags) MUST live on the per-call stack/driver, never on the shared instance. | +| RETRY-43 | MAY | Retry and resilience | A fixed-delay configuration MAY force a flat delay between every retry, disabling exponential growth and jitter; the implementation MUST make the backoff path unreachable in that mode (e.g. by zeroing the base and cap so only the fixed delay applies). | +| RETRY-44 | MUST | Retry and resilience | Each retry attempt MUST re-execute the downstream chain with fresh per-attempt continuation state rather than reusing the prior attempt's in-flight chain, and steps upstream of the retry point MUST NOT mutate the shared in-flight request between attempts (a mutation would leak into the retried send). | +| RETRY-45 | MUST NOT | Retry and resilience | The retry engine MUST NOT shut down or close a caller-supplied scheduler — the caller owns its lifecycle. When no scheduler is supplied, the recovery stack uses a process-wide daemon-thread scheduler that is created at most once VM-wide and is likewise never shut down by the SDK; the async stage stack requires the caller to supply a scheduler explicitly. | +| REDIR-1 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | A redirect is attempted only for response status codes 301, 302, 303, 307, and 308. Any other status (including 2xx, 4xx, 5xx, and non-redirect 3xx) is returned to the caller verbatim without consulting redirect logic. | +| REDIR-2 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | Status 300 (Multiple Choices), 304 (Not Modified), and 305 (Use Proxy) MUST NOT be auto-followed even when a Location header is present. 305 in particular is deprecated and must never redirect a request to a server-chosen proxy. | +| REDIR-3 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | For 301 and 302, a redirect is followed only if the ORIGINAL request method is in the configured allowed-method set (default {GET, HEAD}). When followed, the original method AND body are preserved — there is deliberately NO automatic POST→GET rewrite for 301/302. If the method is not allowed, the 3xx response is returned verbatim. | +| REDIR-4 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | For 307 and 308, the redirect preserves the original request method and body, and is followed only if that method is in the allowed-method set. With the default {GET, HEAD} set, a 307/308 on a non-GET/HEAD method is not followed. | +| REDIR-5 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | 303 (See Other) is NOT followed by default. When explicitly opted in (follow303=true), a 303 is re-issued as a GET with the body dropped and every Content-* request header (case-insensitively, e.g. Content-Type, Content-Length, Content-Encoding) removed. The original request method is irrelevant to whether a 303 is followed (a POST→303 with follow303=true IS followed, as a GET). | +| REDIR-6 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | Any method-preserving redirect (301/302/307/308 that is actually followed) re-sends the original request body, so that body MUST be replayable. If the body is present and not replayable, the operation MUST fail with a clear error (the reference raises an exception whose message names replayability) rather than silently corrupting or truncating the re-send. The redirect is not attempted in that case. 303 is exempt because it drops the body. | +| REDIR-7 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The Authorization request header MUST be stripped before EVERY redirect re-issue — including a same-origin redirect and the 303 GET rebuild. Re-attaching a credential for a known origin is the responsibility of the auth layer, not the redirect layer. | +| REDIR-8 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | A redirect is classified cross-origin iff the resolved target differs from the ORIGINAL (seed) request origin in scheme, host, or effective port (the RFC 6454 origin tuple). Host comparison MUST be case-insensitive and the port MUST be normalized to the scheme's default when omitted. The comparison MUST be against the seed origin, not the immediately preceding hop, so that a same-origin sub-redirect on a foreign host does not re-expose the credential. | +| REDIR-9 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | On a cross-origin redirect (method-preserving or the 303 GET rebuild), the origin-scoped Cookie and Proxy-Authorization request headers MUST also be stripped, matching browser / OkHttp / JDK behavior. | +| REDIR-10 | SHOULD | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | On a SAME-origin redirect, the Cookie header SHOULD be retained (it is legitimately origin-scoped to the same origin). Only Authorization is stripped same-origin. | +| REDIR-11 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | Because the auth layer runs INSIDE the redirect loop and would otherwise re-stamp the caller credential onto the redirect target, a cross-origin re-issue MUST carry an out-of-band signal instructing the auth layer to skip credential stamping. This signal MUST: (a) be impossible for a server-supplied Location to forge into a credential leak — the redirect layer clears any inbound/caller copy of the signal on EVERY re-issue (same-origin included) before conditionally setting its own on a cross-origin hop; (b) only SUPPRESS stamping, never CAUSE a credential to be sent; (c) be removed by the credential-attaching layer before dispatch so it does not reach the wire whenever that layer runs. A same-origin re-issue is NOT signaled and is re-stamped normally. Porter caveat: in the reference the redirect layer sets the marker unconditionally on a cross-origin hop while ONLY the auth step strips it, so a pipeline assembled with no auth step (including the sync standard-resilience preset, which installs no auth step) forwards the internal marker to the transport; a robust port should strip the signal independently of whether a credential layer runs. | +| REDIR-12 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | If the Location target carries userinfo (e.g. https://user:pass@host/…), the userinfo MUST be dropped before re-issue. Server-supplied credentials embedded in a redirect target must never be used. | +| REDIR-13 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | Stripping userinfo (and resolving the Location generally) MUST preserve the wire-exact, already-percent-encoded path, query, and fragment, and MUST preserve bracketed IPv6 literal hosts and explicit ports. In particular, re-encoding that would decode %2F→'/' or %26→'&' is forbidden, as it would silently change path/query structure. | +| REDIR-14 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | A relative Location value MUST be resolved against the CURRENT hop's request URL per RFC 3986 reference resolution (e.g. '/v2/x' relative to 'https://h/v1/x' → 'https://h/v2/x'). Absolute Location values are used as-is (after userinfo stripping). | +| REDIR-15 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | An HTTPS→HTTP scheme downgrade across a single redirect hop MUST be rejected by default (fail with a clear error), preventing TLS/credential guarantees from silently disappearing. A configuration opt-in (allowSchemeDowngrade) permits the downgrade but MUST surface it observably (e.g. a warning log). Credential stripping (REDIR-7/9) applies regardless of this flag. The downgrade check is evaluated on each individual hop transition (from the hop's request URL to its resolved target). | +| REDIR-16 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The step MUST detect redirect loops by recording every visited absolute URI (seeded with the original request URI) and, when a redirect would revisit an already-seen URI, MUST stop and return the CURRENT redirect response to the caller WITHOUT throwing — leaving that response's body open so the caller owns and can inspect it. | +| REDIR-17 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The number of followed redirects MUST be capped by maxHops (default 3). On reaching the cap, the last response is returned as-is even if it is itself a redirect — the step MUST NOT throw on exhaustion. maxHops=0 MUST disable redirect following entirely (the first response is returned untouched). | +| REDIR-18 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | A malformed or unresolvable Location (syntactically invalid URI, illegal characters, or an unsupported/unknown scheme) MUST NOT throw. The step logs the condition (e.g. at warning) and returns the current redirect response to the caller unfollowed. | +| REDIR-19 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | A redirect response with a missing or empty Location header MUST be returned to the caller unfollowed. | +| REDIR-20 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | When a custom redirect predicate is configured, it fully overrides the built-in follow decision and is given a READ-ONLY condition snapshot: the current response, the count of redirects already followed (0 on the first decision), and an insertion-ordered set of visited URIs that includes the current request's URI. That snapshot MUST be a defensive copy so the predicate cannot mutate the live cycle-detection state. | +| REDIR-21 | SHOULD | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | On the non-redirect fast path — a status that is NOT one of the recognized redirect codes (301/302/303/307/308) — the implementation SHOULD short-circuit before allocating a condition snapshot and MUST NOT consult a configured predicate. NOTE for porters: a response that IS a recognized 3xx always allocates the condition snapshot and consults the configured predicate, EVEN when it carries no usable Location — the Location and method checks live inside the default decision logic, not in a pre-predicate fast path. Only a non-redirect status is short-circuited. | +| REDIR-22 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The step MUST manage response-body lifecycle deterministically across the loop: (a) before issuing a follow-up request, the prior redirect response's body MUST be closed; (b) if building the follow-up request throws (e.g. non-replayable body, downgrade rejection), the current response MUST be closed before the error propagates; (c) on any 'return current' outcome (not-a-redirect, opted-out, malformed/missing Location, loop detected, max hops), the returned response is left OPEN for the caller to own and close. | +| REDIR-23 | SHOULD | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | Redirect following SHOULD be implemented as an iterative loop (not unbounded recursion) so it is stack-safe regardless of maxHops. | +| REDIR-24 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The redirect follower MUST wrap the credential-attaching (auth) layer — i.e. the redirect loop is OUTER and the auth stamping runs INSIDE it, per redirect hop. This ordering is what necessitates the unconditional Authorization strip (REDIR-7) plus the cross-origin suppression signal (REDIR-11): without it, the auth layer would re-stamp the caller credential onto every redirect target, including foreign hosts. | +| REDIR-25 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The asynchronous pipeline MUST NOT follow HTTP redirects: the SDK ships no async redirect step and the async standard-resilience preset installs none, so a 3xx surfaces to the async caller verbatim. A porter's async path MUST either (a) leave redirect following to the transport (both reference transports default to followRedirects=false), (b) delegate to the synchronous pipeline, or (c) let callers install their own async redirect step. This asymmetry with the synchronous pipeline (which does install a default redirect step in its resilience preset) MUST be preserved or explicitly documented if changed. | +| REDIR-26 | MUST | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The configured allowed-method set MUST be stored as an immutable defensive copy decoupled from the caller-supplied collection, so mutating the caller's collection after construction cannot change the effective redirect policy. | +| REDIR-27 | MAY | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | The response header from which the redirect target is read MAY be configurable (default 'Location') to accommodate service-specific redirect headers. | +| REDIR-28 | SHOULD | Redirect handling (HTTP 3xx following in the synchronous stage-based pipeline) | Each followed hop, loop detection, and scheme-downgrade event SHOULD be emitted as structured observability records with URLs passed through a redactor before logging, and redaction failures MUST NOT crash logging (a redaction error is swallowed to a placeholder). The malformed-Location event is an exception: it logs the raw Location string as received (it failed to parse into a URL, so it cannot be redacted). A porter that may receive credential-bearing malformed Location values should account for this raw-logging behavior. | +| AUTH-1 | MUST | Authentication | The set of auth schemes the descriptor/resolver layer recognizes MUST be exactly {OAUTH2, API_KEY, BASIC, DIGEST, NO_AUTH}, where NO_AUTH is a distinct sentinel meaning 'this operation may run anonymously / skip credential stamping' rather than a real wire scheme. | +| AUTH-2 | MUST | Authentication | An AuthRequirement MUST bind exactly one scheme to its own OAuth scopes and OAuth params, and those OAuth collections MUST be treated as meaningful only for the OAUTH2 scheme (never inspected by resolution for any scheme, but still preserved for caller inspection). The requirement MUST be immutable: retained input collections mutated by the caller after construction MUST NOT affect the stored value. It has value-based equality over scheme + scopes + params. | +| AUTH-3 | MUST | Authentication | An AuthDescriptor MUST be a non-empty ordered list of requirements in caller preference order, MUST reject an empty requirement list at construction with an argument/validation error, MUST be immutable (defensive copy in and read-only view out), and MUST report allowsAnonymous() true iff any requirement's scheme is NO_AUTH. | +| AUTH-4 | MUST | Authentication | Tier resolution MUST select the single most-specific descriptor that is present, in the strict order per-call > operation > client, and MUST resolve only against that descriptor. A higher tier that is present but cannot be satisfied MUST NOT fall through to a lower tier — it fails, because the caller asked for that override explicitly. A lower tier is consulted only when every higher tier is absent. | +| AUTH-5 | MUST | Authentication | Within the selected descriptor, resolution MUST return the first requirement, in declared order, whose scheme is satisfiable. A scheme is satisfiable iff it is NO_AUTH (always satisfiable) or it is a member of the supplied set of available schemes. The resolver MUST NOT inspect any concrete credential to decide satisfiability. | +| AUTH-6 | MUST | Authentication | Resolution MUST fail with an argument/validation error when all of per-call, operation, and client descriptors are absent, and MUST fail with a distinct auth-resolution error (carrying both the required schemes in preference order and the available schemes) when the selected descriptor lists no satisfiable scheme. | +| AUTH-7 | MUST | Authentication | The resolver MUST be stateless and safe for concurrent use from multiple threads, and a single shared instance MUST be a valid entry point. Resolution MUST be a deterministic pure function of its inputs. | +| AUTH-8 | MUST | Authentication | Every credential type MUST redact its secret material (API key, name-key secret, bearer token) in any string/diagnostic representation, and redaction MUST NOT be achieved by mutating, nulling, or otherwise corrupting the real fields. Non-secret fields (header name, prefix, key name, expiry) MAY remain visible for diagnostics. Equality is variant-specific and MUST be preserved as-is: the bearer token has value-based equality/hashing computed over its real token+expiry fields (unaffected by the redacted string form), while the API-key and name-key credentials use reference identity — two instances with identical fields are NOT equal. | +| AUTH-9 | MUST | Authentication | Credential construction MUST validate secret and identity fields as non-blank and reject blanks with a validation error: BearerToken.token, KeyCredential.apiKey, and NamedKeyCredential.name and key MUST be non-blank. | +| AUTH-10 | MUST | Authentication | BearerToken expiry MUST be optional: a null expiry means the token never locally expires. Expiry evaluation MUST be additive with a grace margin: the token is considered expired at reference time 'now' with margin M iff expiry is non-null and (now + M) is strictly after expiry. A non-expiring token is never expired regardless of margin. | +| AUTH-11 | MUST | Authentication | A bearer token provider's fetch errors MUST propagate to the caller and MUST NOT be cached, so a subsequent request retries the fetch (the not-caching is enforced by the consuming step — see AUTH-35). Async callers MUST observe a provider error through the asynchronous result channel (a failed future/promise), never as a synchronous throw: the default async fetch mirrors the blocking fetch's outcome into an already-failed future, and the async bearer step additionally normalizes a synchronous throw from a misbehaving async override into a failed future. Providers MAY block on the synchronous fetch and SHOULD cache/refresh internally. | +| AUTH-12 | MUST | Authentication | The challenge parser MUST parse RFC 7235 WWW-Authenticate/Proxy-Authenticate values into an ordered list of challenges, honoring: multiple comma-separated challenges at the top level; quoted-string values that may contain commas and '=' without being treated as delimiters; backslash escape sequences unescaped and surrounding quotes stripped; scheme names and parameter names normalized to lower case; parameter values stored verbatim after unquoting; a bare scheme with no params emitted as a valid challenge with an empty parameter map; and a token68 value recorded under the synthetic parameter key 'token68'. | +| AUTH-13 | MUST | Authentication | The challenge parser MUST be lenient and MUST NOT throw on malformed input: blank input yields an empty list, a malformed challenge is skipped by recovering to the next top-level comma, and an unterminated quoted string terminates the current value at end-of-input. Parameters parsed before a malformed tail MUST be preserved on the emitted challenge. | +| AUTH-14 | MUST | Authentication | Basic-scheme stamping MUST produce the header value 'Basic ' + base64(UTF-8 bytes of 'username:password'), computed once and reused. The handler MUST accept a Basic challenge case-insensitively and MUST emit Authorization (or Proxy-Authorization for a proxy challenge). Its credentials MUST be validated as non-empty (permitting whitespace-only values, per RFC 7617), which is intentionally laxer than the non-blank rule used elsewhere. | +| AUTH-15 | MUST | Authentication | Digest stamping MUST support exactly the algorithm set {MD5, MD5-sess, SHA-256, SHA-256-sess} with qop 'auth' (or absent, i.e. legacy RFC 2069 no-qop). It MUST decline (report cannot-handle for) challenges that offer only qop=auth-int, that use SHA-512-256 or any other unsupported algorithm, and MUST NOT attempt mutual-auth (rspauth) verification of the response. | +| AUTH-16 | MUST | Authentication | Digest challenge selection MUST consider a challenge satisfiable iff its scheme is 'Digest' (case-insensitive), it carries both realm and nonce, its qop contains the 'auth' token or is absent, and its algorithm is supported or absent (an absent algorithm MUST default to MD5). Among satisfiable challenges, selection MUST prefer the algorithm appearing earliest in the configured preference list, independent of the order challenges arrived in. | +| AUTH-17 | MUST | Authentication | Digest response computation MUST follow RFC 7616/2069: HA1 = H(username:realm:password) for non-session variants and H(H(username:realm:password):nonce:cnonce) for -sess variants; HA2 = H(method:digest-uri); the response = H(HA1:nonce:nc:cnonce:qop:HA2) when qop is negotiated, or the legacy H(HA1:nonce:HA2) when qop is absent. All hashes MUST be lower-case hex of the selected digest algorithm. | +| AUTH-18 | MUST | Authentication | The Digest nonce count (nc) MUST be tracked per server nonce, starting at 1 (rendered as 00000001) for the first request against a given nonce and incrementing only on reuse of that same nonce. It MUST be rendered as exactly 8 lower-case hex digits, using the low 32 bits when the counter would exceed 32 bits. | +| AUTH-19 | SHOULD | Authentication | The per-nonce request-counter store SHOULD be bounded (default cap 1024 distinct nonces) and drained back under the cap after admitting a nonce, so memory stays bounded against a server that rotates nonces aggressively. Evicting a still-live nonce is acceptable because its nc simply restarts at 1 on next reuse (spec-legal for a fresh nonce). | +| AUTH-20 | MUST | Authentication | The Digest client nonce (cnonce) MUST be drawn from a cryptographically strong random source with at least 128 bits of entropy (16 random bytes) and hex-encoded, so it is unpredictable and cannot be precomputed by an attacker. | +| AUTH-21 | MUST | Authentication | The byte encoding used to materialize Digest hash inputs MUST be UTF-8 when the challenge advertises charset=UTF-8 (case-insensitive) and ISO-8859-1 (Latin-1) otherwise. | +| AUTH-22 | MUST | Authentication | The assembled Digest Authorization value MUST quote username, realm, nonce, uri, response, cnonce, and opaque (with backslash-escaping of embedded quote and backslash) and MUST leave qop, nc, and algorithm unquoted, using the full RFC spelling of the algorithm name (e.g. 'SHA-256-sess'). The digest-uri MUST be the request-target form: raw path (defaulting to '/') optionally followed by '?' and the raw query. cnonce/nc/qop MUST be emitted only when qop is negotiated. | +| AUTH-23 | MUST | Authentication | Composing multiple challenge handlers MUST delegate to the first handler, in declaration order, whose can-handle check passes, and MUST take a defensive copy of the handler list so later caller mutation cannot reorder it. Callers MUST order stronger schemes first (e.g. Digest before Basic) since a server offering both is answered by the first matching handler. | +| AUTH-24 | MUST | Authentication | Challenge handlers MUST be safe for concurrent invocation across requests, and any per-handler mutable counters (e.g. Digest nc) MUST use thread-safe primitives such that concurrent reuse of one nonce still yields correct, non-duplicated counts. | +| AUTH-25 | MUST | Authentication | A challenge handler MUST emit the Authorization header name for WWW-Authenticate challenges and the Proxy-Authorization header name for Proxy-Authenticate challenges, selected by an explicit proxy flag, and MUST return no header (null/none) when it cannot satisfy any offered challenge. | +| AUTH-26 | MUST | Authentication | Static key-credential stamping MUST write the key value into the credential's configured header (defaulting to Authorization) and, when a prefix is configured, MUST prepend it followed by a single space (e.g. 'SharedAccessKey '). The stamping step MUST be stateless after construction. | +| AUTH-27 | MUST | Authentication | There MUST be exactly one auth step occupying the single AUTH pillar stage, and that stage MUST run nested inside both the redirect loop and the retry loop (i.e. AUTH executes per redirect hop and per retry attempt, not once outside them). Ordering: redirect wraps retry wraps auth. | +| AUTH-28 | MUST | Authentication | On any request path where a credential will be attached, the auth step MUST reject a non-HTTPS request URL (scheme comparison case-insensitive) BEFORE performing any token fetch or header stamping, failing with an error that names the concrete step and the offending scheme. Credentials MUST NOT be stamped over plaintext. | +| AUTH-29 | MUST | Authentication | When a request is a cross-origin redirect re-issue (differing scheme, host, or effective port under the RFC 6454 origin tuple, marked by the redirect step), the auth step MUST NOT stamp the caller's credential onto it, MUST strip the internal cross-origin marker so it never reaches the wire, and (because no credential is attached on this path) MUST skip the HTTPS guard so a deliberately-allowed downgrade hop is forwarded credential-free rather than hard-failing. A same-origin redirect re-issue MUST be re-stamped normally and remains subject to the HTTPS guard. The suppression mechanism MUST only be able to suppress stamping, never to force a credential to be sent (a caller-supplied marker is cleared by the redirect step before it can be forged). | +| AUTH-30 | MUST | Authentication | On a 401 (Unauthorized) response that carries a WWW-Authenticate header, the auth step MUST consult its challenge hook; if the hook yields a non-null replacement request, the step MUST close the original 401 response and drive the replacement through a fresh copy of the downstream chain exactly once, with no further challenge handling on that replacement (a single re-challenge attempt only). The default hook MUST yield no replacement (no retry). Non-401 responses, and 401s handled to completion, MUST pass through with their original behavior. | +| AUTH-31 | MUST | Authentication | On the 401 re-challenge replay path the replay MUST be gated on request-body replayability: if the replacement request carries a body that is not replayable, the step MUST skip the replay and surface the original 401 unchanged, and MUST NOT close that original response (the caller owns and consumes it). NOTE: the reference implementation enforces this gate on the SYNCHRONOUS auth step only; the async auth step does not currently apply a replayability gate and closes the original 401 before re-driving the replacement unconditionally. A faithful port SHOULD apply the same gate on both the sync and async paths. | +| AUTH-32 | MUST | Authentication | If the challenge hook itself throws (or its async future completes exceptionally, or the async hook throws synchronously), the auth step MUST close the open 401 response body before propagating the error, so a failing hook never leaks the response. | +| AUTH-33 | MUST | Authentication | A 401 response that does not carry a WWW-Authenticate header MUST be returned unchanged without consulting the challenge hook. | +| AUTH-34 | MUST | Authentication | The bearer auth step MUST stamp 'Authorization: Bearer ' using a token cached until a configurable refresh margin before its expiry (default 30 seconds), and MUST ensure that concurrent requests racing on a missing/expiring token result in at most one provider fetch (double-checked / single-flight coordination), with the hot-path read of a valid cached token being non-blocking. | +| AUTH-35 | MUST | Authentication | The bearer auth step MUST reject a misbehaving provider result: a null token MUST surface as an error, and a token already expired at fetch time (evaluated with NO refresh margin) MUST surface as an error. A provider that throws MUST propagate and MUST NOT be cached, so a later request retries. | +| AUTH-36 | MUST | Authentication | On a 401 whose WWW-Authenticate advertises a Bearer challenge, the bearer auth step MUST evict only the exact cached token that produced the 401 (matched by comparing the stamped header value) and re-stamp the single retry with a freshly fetched token; a token another request already refreshed MUST be preserved and reused. It MUST surface the 401 unchanged (no eviction, no retry) when the rejected request carried no Authorization header (cross-origin suppression) or when the response advertises no Bearer challenge. The eviction-driven retry MUST fire regardless of HTTP method (not gated by idempotency). | +| AUTH-37 | MUST | Authentication | The asynchronous bearer auth step MUST implement a three-zone expiry policy without blocking the dispatching thread: (fresh) stamp the cached token with no refresh; (expiring but still valid) stamp the still-valid cached token immediately AND kick off an off-thread background refresh; (expired/missing) await a fresh single-flight fetch before stamping. Concurrent requests observing an expiring/missing token MUST coalesce onto one in-flight fetch, a failed fetch MUST NOT be cached, and a failed/unusable BACKGROUND refresh MUST NOT fail the in-flight request (log-and-continue) since a valid token was already stamped. The post-eviction challenge path MUST await a genuinely fresh fetch so the retry never re-sends the rejected token. | +| AUTH-38 | SHOULD | Authentication | In the asynchronous auth path, the HTTPS-only guard failure (AUTH-28) and any hook error SHOULD be delivered through the asynchronous result channel (a failed future/promise) rather than by synchronously throwing, so the whole flow remains non-blocking and presents a uniform error model. | +| PAGE-1 | MUST | Pagination | The engine MUST expose two consumption views over the same walk: an item-level view that flattens each page's items into a single ordered sequence, and a page-level view that yields whole pages (each exposing raw per-page status, headers, originating request, and the live response). Items MUST be delivered in server-defined order, across page boundaries. | +| PAGE-2 | MUST | Pagination | A Page MUST partition its state by lifetime: its materialized item list and its derived status code, headers, and originating request MUST remain readable after the page is closed; only the raw response body/connection becomes invalid at close. Items MUST never be null (may be empty). | +| PAGE-3 | MUST | Pagination | A Page MUST be a closeable resource that owns exactly one underlying response; whoever pulls a page owns closing it, and closing the page MUST release that response's body/connection. A component that hands a caller a live page (e.g. a first-page fetcher) MUST NOT itself close the response — ownership transfers to the page. | +| PAGE-4 | MUST | Pagination | A strategy's parse output MUST carry the page's items plus a next-request value, where a null/absent next-request is the single, exclusive end-of-stream signal the iteration engine recognizes. parse MUST always return a well-formed result (never null) and MUST signal termination ONLY by returning a null next-request — never by throwing and never via a side channel (an empty items list paired with a non-null next-request is a valid non-terminal page, not a terminator). Whatever end-of-stream heuristic a concrete strategy applies internally (empty page, empty cursor, missing link — see PAGE-16/17/18) it MUST express by producing that null next-request. | +| PAGE-5 | MUST | Pagination | A strategy MUST read everything it needs from the response synchronously inside parse (the body is single-use), MUST NOT retain the response or its body beyond the call, and MUST NOT close or mutate the response — lifecycle ownership belongs to the engine. Strategies MUST be immutable and safe to share concurrently across paginators and threads. | +| PAGE-6 | MUST | Pagination | Iteration MUST be page-lazy: exactly one HTTP exchange MUST occur per page actually yielded to the consumer. For the blocking engine, constructing the paginator, obtaining the iterable/stream, and even obtaining the item iterator MUST trigger zero exchanges; the first exchange happens only when the consumer first probes for data. The non-blocking engine has no separate lazy 'obtain' step — invoking a walk method is itself the consumption trigger and begins fetching immediately — but the same one-exchange-per-page-consumed guarantee still holds. | +| PAGE-7 | MUST | Pagination | Fetching MUST advance only forward and only on demand: after a page whose strategy returned a null next-request, the engine MUST NOT perform any further exchange, and repeated end-of-stream probes MUST be idempotent (no re-fetch). Empty pages that still carry a non-null next-request DO count as a consumed page (they cost one exchange), but advancing past the terminal page MUST NOT fetch. | +| PAGE-8 | MUST | Pagination | Each independent iteration MUST restart pagination from the initial request with its own fresh state; the engine itself MUST hold only immutable configuration and be safe to share. Concurrent use of a single returned iterator/stream/walk MAY be unsafe (single-consumer), but two separate iterations from the same engine MUST each drive their own full fetch sequence and yield identical results. | +| PAGE-9 | MUST | Pagination | The engine MUST accept a page cap (maximum number of exchanges) that bounds a misbehaving server which never advances its cursor. The cap MUST count exchanges/pages fetched, not items; once the cap is reached the engine MUST stop fetching even if the strategy still reports a non-null next-request. The cap MUST be validated as strictly positive at construction time, failing fast (not lazily on first walk). | +| PAGE-10 | SHOULD | Pagination | The default page cap SHOULD be effectively unbounded (matching plain lazy-sequence semantics), and documentation SHOULD direct production callers to set a finite cap. | +| PAGE-11 | MUST | Pagination | The item-level view MUST eager-close each page BEFORE yielding any of that page's items (after copying the materialized items), so that abandoning item iteration mid-page — taking one item and stopping, breaking early — never strands the page's response/connection. | +| PAGE-12 | MUST | Pagination | The page-level view MUST be auto-closing with a close-on-abandon guarantee: it MUST close the previous page as the consumer advances to the next, close the last page when the walk is exhausted, and — because probing for the next page eagerly runs that page's exchange — buffer the fetched-but-not-yet-delivered page in storage it owns so that an emptiness probe or early break followed by an explicit close still releases it. Explicit close MUST release BOTH the page currently held open and any such buffered page. Consumers MUST be told to wrap the view in a scoped/auto-close construct so an early break still releases the held page. | +| PAGE-13 | MUST | Pagination | If a strategy's parse throws, the engine MUST close that response inline on the exceptional path (the page is never constructed, so nothing else would ever close it) and then propagate the failure. A failure while closing MUST NOT mask the original parse failure — it MUST be attached as a suppressed/secondary error, with the parse error remaining the primary. | +| PAGE-14 | MUST | Pagination | The page-level view MUST be single-use: its iterator/stream may be obtained at most once, and re-iteration MUST fail rather than silently restart or double-consume; a caller restarts pagination by requesting a fresh view from the engine. | +| PAGE-15 | MUST | Pagination | A close error raised while releasing a page-level view's held page(s) MUST be surfaced to the caller, not swallowed. When exposed through a stream whose terminal cannot declare the underlying I/O error type, it MUST be re-thrown wrapped so the caller can still catch it by type at the close site (rather than being sneak-thrown or dropped). When both held pages fail to close, the first failure MUST propagate with the second attached as suppressed so neither connection leaks silently. | +| PAGE-16 | MUST | Pagination | The cursor strategy MUST read a page's items and its next cursor from a single read of the response body (both live in the same payload), MUST treat a null OR empty next cursor as end-of-stream, and when a non-empty cursor is present MUST derive the next request by setting a configurable cursor query parameter (default name 'cursor') on the original request template to the new cursor value. | +| PAGE-17 | MUST | Pagination | The page-number strategy MUST treat an empty items list as end-of-stream (defensive against servers that return an empty page instead of 404 once paged past the end). Otherwise it MUST infer the current page from the ORIGINATING (executed) request's page query parameter, defaulting to a configurable start page (default 1) when that parameter is absent, empty, or non-numeric, and set the next request's page parameter to current+1. The page parameter name (default 'page') and start page (default 1, allowing 0-based servers) MUST be configurable. | +| PAGE-18 | MUST | Pagination | The Link-header strategy MUST select the next-page URL from the response's Link header(s) using RFC 5988/8288 semantics — the first link-value whose rel parameter contains the token 'next' (case-insensitively; rel may be quoted or unquoted and may list multiple space/tab-separated relation types). It MUST parse link-values so that commas inside angle-bracketed URLs or quoted parameter values do NOT split link-values, and support quoted-pair escapes inside quoted strings. Absence of a Link header or of a rel=next segment MUST mean end-of-stream. The header name MUST be configurable (default 'Link'). | +| PAGE-19 | MUST | Pagination | A rel=next target MUST be resolved as an RFC 3986 reference against the originating page's response URL: absolute targets are used as-is; relative targets are resolved against the base. A query-only reference (starting with '?') MUST preserve the base URL's FULL path and replace only the query — it MUST NOT drop the base's last path segment (the older RFC 2396 relative-resolution behavior that would point the next page at the wrong resource). A target that cannot be resolved into a valid URL MUST be treated as end-of-stream rather than aborting iteration with an error. | +| PAGE-20 | SHOULD | Pagination | When a server splits pagination links across multiple separate Link header instances instead of one comma-separated header, the strategy SHOULD normalize them (e.g. by concatenation) so a single parser handles either wire shape, and an empty header set SHOULD map to no next link. | +| PAGE-21 | MUST | Pagination | When rewriting the next-page request's query string, the rebuilder MUST splice the raw query verbatim: every parameter the strategy did not target MUST be copied byte-for-byte (value-less flags stay value-less, reserved characters are not rewritten, ordering is preserved), and ONLY the targeted parameter's name/value is decoded/encoded. It MUST NOT re-render or canonicalize the whole query. | +| PAGE-22 | MUST | Pagination | When the rebuilder encodes a newly set parameter's name/value it MUST use RFC 3986 component encoding (space → %20, a literal '+' preserved/encoded as data — not treated as a space), and reading a parameter MUST decode with the same RFC 3986 semantics (a literal '+' reads back as '+', %20 reads back as a space, a value-less flag reads back as empty string, first match wins). | +| PAGE-23 | MUST | Pagination | Setting a query parameter MUST replace the first existing occurrence in place (dropping any further duplicates of that name — single-value convention for paging params), append the parameter at the end if it was absent, and remove it entirely when the new value is null/absent; parameter order MUST otherwise be preserved. Following an absolute/whole next URL MUST instead swap only the request's URL while preserving the template's method, headers, and body unchanged. | +| PAGE-24 | MUST | Pagination | URL rewriting MUST preserve all non-query components of the URL exactly: scheme/protocol, userInfo, host, port, path, and fragment. Only the query string may change. | +| PAGE-25 | MUST | Pagination | The async engine MUST drive the page-to-page walk without any thread blocking on a page: fetch, parse, deliver-to-consumer, and re-arm the next fetch MUST happen inside the async completion graph. Cancelling/completing the walk's result future from outside MUST halt the walk (no further pages fetched) and best-effort abort the in-flight exchange by cancelling its transport future, which propagates cancellation into the underlying client per the transport contract. | +| PAGE-26 | MUST | Pagination | Async cancellation MUST take effect at page granularity: if the result is settled while a page is mid-drain, the items already being delivered from that page still reach the consumer; the driver stops at the next page boundary rather than interrupting an in-progress drain. A page that was fetched but not yet drained when the result settles MUST be dropped undrained AND closed so its response is not leaked; any close error on that already-settled path MUST be swallowed (the cancellation/failure remains the outcome). | +| PAGE-27 | MUST | Pagination | The async engine MUST close each page's response exactly once, on whichever path consumes it: after the page is drained to the consumer (item- or page-level), or when a fetched-but-undrained page is dropped due to external settlement or a rejected re-dispatch, or inline on a parse failure (page never built). A response MUST NOT be closed twice nor left unclosed on any of these paths. | +| PAGE-28 | MUST | Pagination | In the async engine, a consumer that throws, a transport/connection failure, a parse failure, or a null success completion from the transport MUST terminate the walk and complete the result future exceptionally, surfacing the ORIGINAL underlying cause (unwrapping any future-composition wrapper) rather than a wrapper type. A transport that eagerly throws instead of returning a failed future MUST be handled as a failed walk, not allowed to escape. | +| PAGE-29 | MUST | Pagination | For a single async walk the consumer MUST NOT be invoked concurrently, and items MUST be delivered one at a time in server order; the consumer MUST NOT assume any particular thread. By default the consumer runs inline on whichever thread completes each page future (typically a transport callback thread). The engine MUST also offer a mode that runs the page-draining driver — and therefore every consumer invocation — on a caller-supplied executor, so a blocking/expensive consumer does not tie up transport callback threads. | +| PAGE-30 | MUST | Pagination | If the async engine is given an executor and that executor rejects a (re-)dispatch (shut down or saturated queue), the walk MUST terminate with the result future completed exceptionally carrying the rejection, and any page already staged at that point MUST be closed so its response is not leaked. It MUST NOT hang the result future or leak the rejection into the completion machinery. | +| PAGE-31 | SHOULD | Pagination | The async driver SHOULD process synchronously-completed page futures (in-memory/fake transport, cache hit) iteratively rather than via recursive future composition, so an arbitrarily long run of already-complete pages does not overflow the call stack. A port on a runtime without deep-recursion risk MAY satisfy the intent with its native loop/concurrency model, but MUST NOT recurse per page. | +| PAGE-32 | MUST | Pagination | In the async drain path, releasing a page's response MUST happen whether the consumer succeeds or throws, and a throwing close MUST NOT escape the driver: on the success path a throwing close MUST be reported through the result future (so the walk terminates cleanly instead of hanging with the future never settled); if the consumer already failed, that cause is kept as primary and the close error is swallowed. | +| PAGE-33 | MUST | Pagination | There is one inherent async cancellation race a port MUST document rather than pretend to close: if an external cancel settles the transport future BEFORE the transport delivers its Response, that Response never reaches the paginator's close path — releasing it is the transport's responsibility, since cancelling the walk's future cannot reach into an already-built response. Conversely, a page request already dispatched MAY still complete after the abort; if it completes successfully the paginator MUST close and discard that response. | +| PAGE-34 | MUST | Pagination | The fetcher-based front-end MUST call the first-page fetcher exactly once to start, then drive subsequent pages by keying the next-page fetcher off the previous page's next link, falling back to its continuation token when no next link is present (next link wins). An empty/blank next link (with no fallback token), or a null page returned by either fetcher, MUST end the stream (a null first page yields an empty stream). Each fetcher MUST build a page that owns its response and MUST NOT close that response itself; a fetcher that throws before building the page remains responsible for that response. | +| PAGE-35 | SHOULD | Pagination | If a mutable paging-options object is offered to fetchers, the SAME instance SHOULD be threaded through every fetcher call so a custom retriever can stash the latest cursor/state between pages, and this cross-call mutation visibility SHOULD be documented; such an options object is single-consumer and need not be thread-safe. | +| PAGE-36 | MUST | Pagination | Per-call request overrides (timeout, retry budget, tags, etc.) supplied to the strategy-based engine MUST be applied to EVERY page exchange, not just the first, so a caller's per-operation options reach all of the paginator's own requests. The default is no overrides. | +| SSE-1 | MUST | Server-Sent Events and streaming | The stream MUST be parsed line by line, with a blank (empty) line acting as the event-dispatch boundary: at a blank line the fields accumulated since the previous boundary collapse into exactly one event, and a fresh set of per-event accumulators (id, event, data, comment, retry, and the 'any field seen' flag) governs the next block. | +| SSE-2 | MUST | Server-Sent Events and streaming | Line termination MUST recognize all three SSE-legal terminators — LF ('\n'), CR ('\r'), and CRLF ('\r\n') — treating CRLF as a single terminator, with terminators stripped from line content; a CR not followed by LF terminates the line by itself. | +| SSE-3 | MUST | Server-Sent Events and streaming | A non-comment line MUST be split at its first colon into a field name and a value. If the line contains no colon, the entire line is the field name and the value is the empty string; if the colon is the final character, the value is likewise the empty string. | +| SSE-4 | MUST | Server-Sent Events and streaming | A field that is present with an empty value (a colon-less 'data', a trailing-colon 'data:', or 'event:'/'id:' with nothing after the single-space strip) MUST be recorded with the empty string as its value AND MUST count as a 'field seen'; this is a distinct state from the field being absent (surfaced as null/absent). A port MUST NOT collapse a present-but-empty field into an absent one. | +| SSE-5 | MUST | Server-Sent Events and streaming | When extracting a field value (and comment text), exactly one leading U+0020 SPACE immediately after the colon MUST be stripped if present; any further leading spaces are preserved. | +| SSE-6 | MUST | Server-Sent Events and streaming | A line whose first character is ':' MUST be treated as a comment: its text (after the single-space strip) is captured with latest-wins semantics within a block, and a comment counts as a 'field seen' so a comment-only block is still dispatched as a (non-empty) event. | +| SSE-7 | MUST | Server-Sent Events and streaming | Only the field names 'id', 'event', 'data', and 'retry' MUST be interpreted; any other field name MUST be silently discarded (it sets no state and does not by itself cause a dispatch). | +| SSE-8 | MUST | Server-Sent Events and streaming | Consecutive 'data' fields within a block MUST accumulate, in wire order, into an ordered list of the raw per-line values; the parser MUST NOT join them at the parse layer. | +| SSE-9 | MUST | Server-Sent Events and streaming | An 'id' field whose value contains a U+0000 NUL character MUST be ignored entirely: it does not set the id, does not count as a 'field seen', and does not overwrite a valid id already seen earlier in the same block. A valid id is otherwise stored verbatim, latest-wins within a block. | +| SSE-10 | MUST | Server-Sent Events and streaming | The 'event' field MUST be stored as the raw value with latest-wins semantics within a block, and MUST be surfaced as absent/null when no 'event' field was sent — it MUST NOT be defaulted to 'message'. | +| SSE-11 | MUST | Server-Sent Events and streaming | The 'retry' field value MUST be accepted only if it consists solely of ASCII digits '0'-'9'; a leading sign, an embedded non-digit, an empty value, or a value exceeding the maximum representable millisecond magnitude MUST cause the field to be ignored (leaving retry unset and not marking the block dispatchable on its own account). An accepted value is surfaced as a non-negative millisecond duration, latest-wins within a block. | +| SSE-12 | MUST | Server-Sent Events and streaming | A single leading UTF-8 BOM (bytes EF BB BF / U+FEFF) at the very start of the stream MUST be consumed and discarded exactly once, using non-consuming lookahead so that a non-BOM prefix is left intact; any BOM occurring later in the stream MUST be preserved as ordinary data. | +| SSE-13 | MUST | Server-Sent Events and streaming | Dispatch MUST be permissive relative to WHATWG: an event is emitted whenever ANY of the five tracked fields (id, event, data, comment, retry) was set in the block — so id-only, retry-only, and comment-only events are visible — while a block in which no field was set (pure blank lines) MUST be skipped and MUST NOT emit an event. | +| SSE-14 | MUST | Server-Sent Events and streaming | At end-of-stream, if fields have accumulated but no terminating blank line was seen, the parser MUST dispatch the pending event (any field seen) rather than discarding it; if no field was accumulated it MUST signal end (no event). A final line without a terminator at EOF is returned as content. | +| SSE-15 | MUST | Server-Sent Events and streaming | next() MUST return an end-of-stream sentinel (null/None/absent) exactly when the source is exhausted with no pending dispatchable fields, and MUST continue to report end on subsequent calls (no spurious events after end). | +| SSE-16 | MUST | Server-Sent Events and streaming | The reader MUST be single-pass and stateful: only the 'BOM already consumed' flag persists across next() calls; the last-event-id is NOT carried forward between events — each event surfaces only the id present in its own block. A reimplementation MUST NOT maintain a WHATWG-style persistent last-event-id buffer inside the parser. | +| SSE-17 | MUST | Server-Sent Events and streaming | The reader MUST NOT own or close the underlying byte source; source lifecycle is the caller's responsibility. (Resource ownership is introduced only by the SseStream facade — see SSE-23.) | +| SSE-18 | MUST | Server-Sent Events and streaming | A single reader instance MUST be driven from one thread at a time; the parser is not internally synchronized and offers no thread-safety for concurrent next() calls. A port MAY leave the parser non-thread-safe. | +| SSE-19 | MAY | Server-Sent Events and streaming | The parser MAY accept arbitrarily long lines / data values with no built-in size cap; the reference implementation imposes no maximum line or event size (a growable byte accumulator expands by doubling). | +| SSE-20 | MUST | Server-Sent Events and streaming | The parsed event value MUST be immutable and hold a defensively-copied, read-only data list so that neither the caller's originally-supplied list nor later mutations can reach inside a constructed event, and any copy-with-changes operation MUST likewise copy the data list. | +| SSE-21 | SHOULD | Server-Sent Events and streaming | The event value SHOULD provide structural value semantics — equality and hash derived from all five fields (id, event, data, comment, retry), and a stable string form — so events can be compared and used in collections. | +| SSE-22 | SHOULD | Server-Sent Events and streaming | The event value SHOULD expose an 'is-empty' predicate that is true only when all five fields are unset/empty; because a comment counts as content, a comment-only (keep-alive) event SHOULD report non-empty. | +| SSE-23 | MUST | Server-Sent Events and streaming | The streaming facade MUST own exactly one closeable resource (typically the HTTP response/body) and MUST close it exactly once across the stream's whole life, regardless of how the stream terminates (clean end, explicit close, use-block / try-with-resources exit, partial consume, or mid-stream failure). | +| SSE-24 | MUST | Server-Sent Events and streaming | When the underlying reader signals end-of-stream during iteration, the facade MUST both terminate the iterator cleanly AND release the owned resource, so a fully-consumed stream needs no explicit close. | +| SSE-25 | MUST | Server-Sent Events and streaming | A partial consume MUST NOT strand the resource: closing the stream (explicitly, via a use/try-with-resources block, or via cancellation) after reading only some events MUST release the owned resource. | +| SSE-26 | MUST | Server-Sent Events and streaming | The facade MUST be single-pass: obtaining an iterator succeeds at most once; a second attempt MUST fail loudly (e.g. an illegal-state error), because the backing reader is single-pass and stateful. | +| SSE-27 | MUST | Server-Sent Events and streaming | After the stream is closed, requesting an iterator (and any further pulls from an already-in-flight iterator) MUST NOT read from the torn-down resource: a fresh iterator request MUST fail loudly, and an in-flight iterator MUST observe the closed state and end cleanly on its next pull. | +| SSE-28 | MUST | Server-Sent Events and streaming | close() MUST be idempotent — only the first call propagates to the owned resource; subsequent calls are no-ops — and this idempotence MUST hold even after an automatic release already occurred on a terminal/failure path. | +| SSE-29 | MUST | Server-Sent Events and streaming | A mid-stream reader failure MUST release the owned resource BEFORE the error propagates to the consumer, so a consumer iterating without a use-block never strands the connection; if releasing the resource itself fails while an error is in flight, that release failure MUST be attached to the primary error as a suppressed/secondary throwable (not swallowed, not replacing the primary). | +| SSE-30 | MUST | Server-Sent Events and streaming | A failure to release the resource on an AUTOMATIC clean-terminal path (natural end-of-stream or a done-sentinel, with no error in flight) MUST NOT be turned into a thrown result that discards already-delivered events; it MUST instead be reported out-of-band and swallowed. By contrast, a release failure during an EXPLICIT close() MUST propagate to the caller. | +| SSE-31 | MUST | Server-Sent Events and streaming | close() MUST be safe to invoke from a different thread than the one iterating (to cancel a long-lived stream): the closed state is guarded atomically. A close observed BETWEEN pulls ends iteration cleanly; a close that tears the resource down while a read is blocked IN-FLIGHT surfaces to the iterating thread as a read failure (an I/O error), not a clean end. Either way the resource is released exactly once. | +| SSE-32 | MUST | Server-Sent Events and streaming | The convenience that opens a stream over an HTTP response MUST bind the stream's lifecycle to that response's body (closing the stream closes the response and releases its connection) and MUST fail loudly if the response has no body. | +| SSE-33 | MUST | Server-Sent Events and streaming | The typed adapter MUST invoke the caller's mapper with (event-name, joined-data) where event-name is the event's raw 'event' field (absent/null if omitted) and joined-data is the event's data lines joined with a single '\n' separator (empty string when the event had no data field), and MUST yield the mapper's decoded value to the consumer. | +| SSE-34 | MUST | Server-Sent Events and streaming | The typed adapter MUST honor the mapper's three outcomes: a value is yielded to the consumer; a Skip result silently drops the event and advances to the next (never surfacing to the consumer); a Done result ends iteration cleanly and closes the underlying stream/resource without yielding a model for the sentinel event itself. | +| SSE-35 | MUST | Server-Sent Events and streaming | Typed decoding MUST be lazy and per-element: the mapper (and any serialization/deserialization it performs) runs only when the consumer pulls the next element, so a partial consume decodes only the events actually taken. | +| SSE-36 | MUST | Server-Sent Events and streaming | A mapper that throws (decode failure or a mapped error-envelope) MUST propagate the exception to the consumer's pull, but MUST first release the underlying stream/resource; a resulting release failure MUST be attached to the mapper error as a suppressed/secondary throwable. | +| SSE-37 | MUST | Server-Sent Events and streaming | Core parsing and streaming MUST remain format- and API-agnostic: they MUST hold no built-in done-sentinel, no error-envelope recognition, and no serialization dependency — all such conventions MUST live only in the caller-supplied mapper seam. | +| SSE-38 | MUST | Server-Sent Events and streaming | Reconnection and last-event-id continuity MUST remain the caller's responsibility: the subsystem surfaces the server's retry hint and each event's raw id but MUST NOT itself auto-reconnect, MUST NOT persist a last-event-id across events (see SSE-16), and MUST NOT set any reconnect request header. A callback listener contract MAY be offered whose retry/close/error hooks default to no-ops and are not auto-driven by core. | +| SSE-39 | MUST | Server-Sent Events and streaming | Event delivery MUST be pull-based (demand-driven) with no eager read-ahead: the parser advances the byte source only when the consumer requests the next event, so a blocking source read is the backpressure mechanism and no unbounded internal event buffer accumulates. A reactive/async adapter MUST preserve this — polling the source at most once per unit of downstream demand. | +| SSE-40 | SHOULD | Server-Sent Events and streaming | Sequence/iterable convenience views over a raw source SHOULD be lazy, single-pass, and propagate read exceptions to the consumer at the offending pull; they SHOULD reuse one reader instance so per-stream state (e.g. BOM consumption) is preserved, and MUST NOT be invoked twice on the same source (a second view would resume mid-stream). | +| SSE-41 | MAY | Server-Sent Events and streaming | A reactive adapter MAY choose to catch only recoverable exceptions and let unrecoverable errors (the runtime's fatal/VM error family) escape rather than routing them through the stream's error channel, and MAY leave source lifecycle to the caller (not closing the source on terminal/cancel signals). | +| SERDE-1 | MUST | Serialization (Serde) | A Serde MUST be a single bundle that exposes exactly one encoder (serializer) and one decoder (deserializer) for one wire format, so consumers acquire both through one reference rather than wiring separate strategies. | +| SERDE-2 | MUST | Serialization (Serde) | A Serde MUST declare the wire media type it produces, and that media type MUST be used as the default Content-Type when a request body is created from a value plus a Serde. The media type MUST NOT be defaulted to a format-agnostic constant at the SPI level. | +| SERDE-3 | MUST | Serialization (Serde) | When encoding into a caller-supplied stream, or decoding from a caller-supplied stream, the serializer/deserializer MUST read/write the payload fully (to EOF on the read side) but MUST NOT close or take ownership of the caller's stream; the caller retains ownership and responsibility for closing. The encode-into-buffer profile likewise touches only the target region and never assumes ownership. | +| SERDE-4 | MUST | Serialization (Serde) | The encode-into-buffer profile MUST return the number of bytes written, MUST honor a start offset, and MUST throw a range/overflow error (distinct from the serde exception type and not chaining one) when the offset is out of range or the encoded payload does not fit in the remaining space. Bytes before the offset MUST be left untouched. | +| SERDE-5 | MUST | Serialization (Serde) | Every decode operation MUST take an explicit runtime type witness for the target type. A decoder MUST NOT rely on erased compile-time generics to recover the target, because on an erasure-based runtime that silently yields an untyped map/list which detonates as a cast error on first field access (heap pollution). | +| SERDE-6 | MUST | Serialization (Serde) | Parametric decode targets (e.g. List, Map) MUST be expressible through a full-generic type carrier that preserves element types across erasure. A format-agnostic decoder that cannot resolve type arguments MUST fail loudly (serde exception) for a genuinely parametric carrier rather than silently decoding into the wrong (raw) type; a carrier wrapping a plain class MUST still decode via the raw path; only a codec that resolves generics may satisfy genuinely parametric targets. | +| SERDE-7 | MUST | Serialization (Serde) | An ergonomic reified/inline decode helper (where the host language offers one) MUST capture the full generic type of the target and route through the generic type carrier, not forward only the raw class. A concrete target still decodes identically, but a parametric target must reify its full type. | +| SERDE-8 | MUST | Serialization (Serde) | The generic type carrier MUST capture a concrete, fully-resolved type at construction and MUST reject construction that provides no type argument or an unresolved type variable (e.g. created inside a generic function/subclass where the argument erases to its bound), failing fast with an actionable message. | +| SERDE-9 | MUST | Serialization (Serde) | Encode/decode failures MUST be surfaced as the SDK's stable, format-agnostic serde exception type (or a subtype). Adapters MUST catch the backing codec's own processing failures and rethrow as the serde type, MUST chain the original failure as the cause, and MUST NOT allow a backing-library exception type to escape the SPI. | +| SERDE-10 | MUST | Serialization (Serde) | Failures on the write path MUST be reported as a serialization-specific subtype and failures on the read path as a deserialization-specific subtype, both of the common serde exception root, so callers can distinguish direction while still catching one stable base type. | +| SERDE-11 | SHOULD | Serialization (Serde) | Serde failures SHOULD be unchecked (runtime) rather than a checked/declared exception, so callers are not forced to wrap every round-trip in try/catch. | +| SERDE-12 | MUST | Serialization (Serde) | A genuine stream I/O error (e.g. connection reset, disk write failure) raised while reading from or writing to a caller-owned stream MUST propagate unwrapped as an I/O error, and MUST NOT be re-wrapped as a serde (codec) exception. Only malformed-input / shape-mismatch / unencodable-value failures are wrapped. | +| SERDE-13 | MUST | Serialization (Serde) | Decoding a wire null literal into a non-null target type MUST fail with a deserialization exception naming the target type, across every decode overload (raw class, generic carrier, and any codec-native type-reference path the adapter exposes). It MUST NOT return a null that flows through the non-null result and detonates later. | +| SERDE-14 | MUST | Serialization (Serde) | The PATCH tri-state type MUST model exactly three states — Absent (key missing), Null (explicit null), Present (carries a value) — and MUST make the illegal fourth state (a present value that is itself null) unrepresentable through the public API by bounding Present to non-null values. The type SHOULD be covariant in its value type so Absent/Null satisfy any target parameterization. | +| SERDE-15 | MUST | Serialization (Serde) | When serializing a PATCH tri-state field within an object: Absent MUST omit the key entirely from the output; Null MUST emit the key with a wire null value; Present MUST emit the key with the encoded inner value. This is the central interop invariant of the subsystem. | +| SERDE-16 | MUST | Serialization (Serde) | When deserializing a PATCH tri-state field: a missing key MUST decode to Absent, a present explicit-null MUST decode to Null, and a present value MUST decode to Present(value) with the inner value's declared element type preserved. | +| SERDE-17 | MUST | Serialization (Serde) | A tri-state field with no key on the wire MUST resolve to Absent. In practice this requires the field's declared default to be Absent (and the decoder's empty-value fallback to yield Absent), because a missing key is short-circuited by the codec before the decoder's null hook runs. | +| SERDE-18 | SHOULD | Serialization (Serde) | The tri-state type SHOULD provide construction and consumption helpers: factories for absent, explicit-null, present(non-null), and a nullable-to-(present\|null) mapper that can never yield Absent; plus a three-way fold, a value-or-null accessor, and is-absent/is-null/is-present predicates. | +| SERDE-19 | MUST | Serialization (Serde) | The default codec configuration MUST wire the tri-state PATCH semantics (omit-on-absent, null-on-null). An adapter that builds a serde around a caller-supplied codec MUST register that wiring by default, and MAY allow opting out only for a caller that has already installed equivalent wiring. Absent this wiring, Absent and Null become indistinguishable on the wire and PATCH payloads are silently corrupted. | +| SERDE-20 | SHOULD | Serialization (Serde) | When a tri-state value is serialized with no enclosing object able to omit a key (a top-level value, or an element inside an array), the implementation SHOULD degrade gracefully: emit a wire null for both Absent and Null (there is no key to omit at top level), and for an array element emit null for Absent rather than throwing. This degradation is a documented compromise, not the primary contract. | +| SERDE-21 | MUST | Serialization (Serde) | The default decoder configuration MUST reject cross-shape scalar coercions instead of silently reshaping them: string->integer, string->floating-point, string->boolean, empty-string->integer/floating-point/boolean, floating-point->integer (lossy narrowing), boolean->integer, integer->boolean, boolean->floating-point, and any non-string scalar->string. A rejected coercion MUST surface as a deserialization failure. | +| SERDE-22 | MUST | Serialization (Serde) | The strict-coercion policy MUST still permit representation-preserving conversions: numeric widening of an integer wire value into a floating-point target, an empty string into a textual target, and any genuinely well-typed value binding to its matching target. | +| SERDE-23 | SHOULD | Serialization (Serde) | The default decoder configuration SHOULD ignore unknown/unexpected fields in an incoming document rather than failing, so a server can add backward-compatible fields ahead of a client model update without breaking existing clients. | +| SERDE-24 | SHOULD | Serialization (Serde) | The default encoder configuration SHOULD emit date/time values as ISO-8601 strings, not as numeric epoch timestamps, matching prevailing public REST API conventions; whichever form is chosen, the encoding MUST round-trip back to the same instant. | +| SERDE-25 | SHOULD | Serialization (Serde) | A factory that builds the default codec configuration SHOULD return a fresh, independent instance on each call rather than a shared mutable singleton, because codec instances carry mutable caches that interact poorly with post-construction reconfiguration. | +| SERDE-26 | MUST | Serialization (Serde) | When a serde is built around a caller-supplied codec instance, the SDK MUST NOT mutate the caller's instance during normal construction; it MUST operate on a private copy of the codec engine (e.g. to register the tri-state wiring). If the codec cannot be copied, the implementation MAY fall back to using the supplied instance directly, but this fallback (which does mutate) MUST be documented behavior, not silent. | +| SERDE-27 | MUST | Serialization (Serde) | A response-decoding handler MUST stream the HTTP response body directly through the deserializer into the target value (without first materializing the whole body as a string/byte array), MUST consume and close the response on every path (success and failure), MUST surface a missing body (e.g. 204 No Content) as a serde exception whose message names the target type, and MUST surface a codec/parse failure as a serde exception with the original codec failure chained as cause while letting a genuine mid-stream I/O error propagate unwrapped. | +| SERDE-28 | MUST | Serialization (Serde) | A status-aware decoding handler MUST decode the body only on a 2xx status. On a 4xx/5xx status it MUST throw the mapped HTTP-error exception carrying a bounded, buffered in-memory copy of the error body (so the error body remains readable after the live response is closed) instead of attempting to decode the error payload as the success type. On any other non-2xx status (1xx, or an unfollowed 3xx such as 304) it MUST close the response and raise a serde exception whose message leads with the status code and preserves conditional/redirect context (e.g. ETag / Location). | +| SERDE-29 | SHOULD | Serialization (Serde) | A configured serde SHOULD be safe to share across concurrent threads/tasks once its configuration is complete and no longer mutated; any per-type sub-serializer/sub-deserializer caches the implementation keeps SHOULD use non-blocking, publication-safe updates rather than coarse locks. | +| SERDE-30 | MAY | Serialization (Serde) | The absent and explicit-null sentinels MAY provide a stable, identity-free textual representation (e.g. "Absent", "Null") so logs and assertions do not leak an identity hash. | +| OBS-1 | MUST | Instrumentation and observability | When the requested log level is disabled on the underlying logger, obtaining a log event and calling its builder methods and terminal emit MUST allocate nothing and produce no output. The facade MUST decide enabled/disabled once, at event-creation time, and return a shared inert event for the disabled case. | +| OBS-2 | MUST | Instrumentation and observability | The facade MUST expose exactly four severity levels — ERROR, WARNING, INFO, VERBOSE — and map them onto the backend's ERROR, WARN, INFO, and most-verbose/DEBUG levels respectively. | +| OBS-3 | MUST | Instrumentation and observability | A field key MUST be rejected (error to the caller) when empty. A null field value MUST NOT be dropped; it MUST be emitted as the literal string 'null'. | +| OBS-4 | MUST | Instrumentation and observability | event(name) MUST set an authoritative categorisation tag under the reserved key 'event'. An empty name MUST clear the tag rather than emit 'event='. When a non-empty tag is set, any 'event' key arriving from the global context, folded diagnostic context, or a per-event field MUST be suppressed so the emitted event carries the 'event' key exactly once. | +| OBS-5 | MUST | Instrumentation and observability | When the same field key would be contributed by more than one source, precedence MUST be: per-event field wins over global context, and both win over folded diagnostic context. A given key MUST appear at most once in the emitted event. | +| OBS-6 | MUST | Instrumentation and observability | Field-value rendering MUST be total (never throw) and MUST special-case: throwables to 'SimpleClassName: message'; arrays/collections/maps to a bracketed textual form; and pass numeric/boolean/char primitives through type-preserving. If a value's own string conversion throws, the facade MUST substitute a diagnostic placeholder instead of propagating. | +| OBS-7 | SHOULD | Instrumentation and observability | A rendered field value SHOULD be truncated to a bounded maximum length (reference: 8 KiB) with a truncation marker suffix, to cap log volume from oversized values. Primitives are exempt. | +| OBS-8 | MUST | Instrumentation and observability | A single log event MUST be emitted at most once. A second terminal emit on the same event instance MUST be a no-op, and this guard MUST be correct under concurrent invocation. Field/tag/cause accumulation is not required to be thread-safe (single-thread build), but the terminal emit MUST be safe to call from any thread. | +| OBS-9 | MUST | Instrumentation and observability | A global key/value context configured on the logger MUST be attached to every event emitted through that logger (subject to the duplicate-key precedence in OBS-5). For hot-path efficiency the implementation SHOULD reference the caller-supplied context rather than deep-copying it per event; the context is therefore expected to be effectively immutable. | +| OBS-10 | MUST | Instrumentation and observability | When folding thread-local diagnostic context into an event, only keys in the configured allow-list MUST be folded. The default allow-list MUST be exactly {trace.id, span.id}. A null (absent) allow-list MUST fold every present diagnostic-context key (opt-in unfiltered mode). Keys with null values MUST be skipped. | +| OBS-11 | MUST | Instrumentation and observability | URL userinfo (the 'user:password@' component) MUST always be redacted to a fixed placeholder ('***:***@'), unconditionally and independent of any allow-list. | +| OBS-12 | MUST | Instrumentation and observability | URL query-parameter values MUST be redacted to '***' unless the parameter name (decoded, compared case-insensitively) is in the allow-list. The default query allow-list MUST be exactly {api-version}. An empty allow-list MUST redact every value. Multi-value keys MUST be treated atomically (all values for a name kept or all redacted). Parameter names and the '=' separator MUST be preserved. | +| OBS-13 | MUST | Instrumentation and observability | A URL fragment MUST be scrubbed under the same allow-list as query parameters: 'key=value' tokens in the fragment MUST be redacted like query values, while a plain fragment with no '=' MUST be preserved verbatim. | +| OBS-14 | MUST | Instrumentation and observability | URL redaction MUST NOT alter scheme, host, port, or path, and MUST preserve a present-but-empty query (a trailing '?'). A '?' that appears only inside the fragment MUST NOT be treated as a query delimiter (no spurious separator inserted). A trailing '&' (empty final pair) in query or fragment MAY be dropped. | +| OBS-15 | MUST | Instrumentation and observability | URL redaction MUST be total: on any parse/rebuild failure it MUST return a fixed sentinel ('[malformed url]') rather than throwing. Logging must never break the caller. | +| OBS-16 | MUST | Instrumentation and observability | A URL that arrives as a header value MUST be redacted. A parseable absolute value is redacted exactly like a request URL; when the value is relative or otherwise unparseable, the redactor MUST keep the path and drop everything after it (both query and fragment), appending a fixed '?***' marker whenever the value carried a query OR a fragment — since those can carry an OAuth code, a pre-signed signature, or an implicit-flow token. A value with neither MUST be returned verbatim. | +| OBS-17 | MUST | Instrumentation and observability | When logging header values, values of URL-valued response headers (at minimum Location and Content-Location) MUST be redacted through the URL-value redactor; other header values pass through unchanged. The redaction policy MUST be shared by the sync and async logging paths so it cannot drift. | +| OBS-18 | MUST | Instrumentation and observability | Header logging MUST gate which header NAMES are logged by an allow-list. A header whose name is not allow-listed MUST NOT have its value logged; depending on a boolean policy it is either emitted with a fixed redaction marker ('REDACTED') or omitted entirely. The default header-name allow-list MUST contain only diagnostic, non-credential headers. | +| OBS-19 | SHOULD | Instrumentation and observability | A transport that drops a caller-set request header it cannot encode SHOULD surface the drop with a configurable verbosity policy offering at least: log every occurrence at WARN; log the first drop per header name at WARN and subsequent drops of that name at verbose; or log only at verbose. The default SHOULD be the once-per-header-name policy to avoid flooding a hot path. | +| OBS-20 | MUST | Instrumentation and observability | Emitting structured log events around a request MUST NOT be able to fail the request: every log-emission site (request event, response event, failure event, and the body-drain that feeds them) MUST catch any exception and re-surface it as a best-effort 'http.instrumentation.*' diagnostic, and a secondary failure while emitting that diagnostic MUST be swallowed. The runtime does NOT defensively wrap tracer (span start, scope activation, end) or metrics (counter/histogram) calls; their non-failure guarantee rests instead on the SPI contract that those callbacks never throw (OBS-30), so a throwing tracer or meter WILL propagate and can fail the request. | +| OBS-21 | MUST | Instrumentation and observability | A Span MUST expose a recording flag; when non-recording, all mutators (attribute/error) MUST be inert and drop their data, and end() MUST be a no-op. end() (both success and error variants) MUST be idempotent — a second call, or a call on a non-recording span, MUST be a documented no-op. | +| OBS-22 | MUST | Instrumentation and observability | Activating a span as the current span MUST return a scope handle that, when closed, restores the previously-active span. The scope MUST be closeable from a try/using construct and MUST restore even when the guarded code throws. | +| OBS-23 | MUST | Instrumentation and observability | Activating a span for log correlation MUST push the trace id and span id onto the thread-local diagnostic context (under keys 'trace.id' and 'span.id') for the scope's lifetime, and MUST restore each key to its prior value (or remove it if previously unset) on close. For a non-recording span the push MUST be skipped and activation MUST delegate to plain current-span activation. | +| OBS-24 | MUST | Instrumentation and observability | Thread-local diagnostic context MUST be bridgeable across async thread boundaries via an immutable snapshot: capture on the originating thread, then reinstall on the executing thread for the duration of a block and restore that thread's prior context afterward (including on exception). The snapshot itself MUST be immutable/shareable, and mutations MUST affect only the calling thread. | +| OBS-25 | MUST | Instrumentation and observability | The tracing abstractions MUST provide allocation-free no-op defaults used whenever tracing is disabled: a no-op Tracer returning a shared no-op Span, a no-op Span whose current-scope is a cached singleton, a no-op instrumentation context whose identifiers are all invalid sentinels, and a no-op HTTP-tracer / tracer-factory. Selecting a no-op path MUST NOT allocate per call. | +| OBS-26 | MUST | Instrumentation and observability | An instrumentation/trace context MUST expose W3C-Trace-Context-compliant identifiers: a trace id, a span id (16 lowercase hex chars), trace flags (a two-hex-char byte, e.g. the sampled bit), and a vendor trace-state list. It MUST expose validity and remoteness flags. The reserved invalid sentinels MUST be: trace id of 32 hex zeros, span id of 16 hex zeros, trace flags '00', and empty trace-state; an all-zero trace/span id MUST be treated as invalid/no-trace. | +| OBS-27 | MUST | Instrumentation and observability | Trace-id generation MUST support at least the W3C flavour (128-bit value rendered as 32 lowercase hex chars) and a Datadog flavour (64-bit unsigned integer rendered as a decimal string), plus a no-op flavour that always yields the invalid sentinel. Generation MUST NOT produce the reserved all-zero id — a zero draw MUST be coerced to a non-zero value. | +| OBS-28 | SHOULD | Instrumentation and observability | The SDK SHOULD provide an HTTP-shaped tracer event vocabulary richer than start/end: operation started/succeeded/failed; per-attempt started/failed(with next-delay)/retries-exhausted; and transport milestones (request URL resolved, connection acquired with host+port, request sent with byte count, response headers received with status+headers, response received with byte count). Every event method SHOULD default to a no-op so adding a new event is a non-breaking change and implementers override only what they need. | +| OBS-29 | MUST | Instrumentation and observability | HTTP-tracer lifecycle ordering MUST hold: operationStarted fires once at the start; operationSucceeded and operationFailed are mutually exclusive and each fires exactly once at the end; attempt events may fire multiple times; retries-exhausted (when it fires) is immediately followed by operationFailed with the same throwable. One tracer instance corresponds 1:1 to a single logical operation lifecycle (created by the factory per operation). This is a documented emission contract; pipeline/transport wiring to emit it is a follow-up, so it is not yet runtime-enforced. | +| OBS-30 | MUST | Instrumentation and observability | Tracer and HTTP-tracer callbacks MUST be safe to invoke concurrently and from transport threads other than the caller's, and implementations MUST NOT throw from any callback. The metrics instruments MUST likewise be safe to call concurrently from request threads and MUST NOT throw. The instrumentation runtime does not defensively catch these callbacks (see OBS-20), so violating must-not-throw breaks the caller's request. | +| OBS-31 | MUST | Instrumentation and observability | The metrics SPI MUST expose a Meter that manufactures at least a monotonic integer counter and a floating-point histogram, each accepting per-measurement key/value attributes. The default Meter MUST be a no-op that discards every measurement and returns shared instrument singletons, and the core MUST NOT pull a metrics runtime into its dependencies. | +| OBS-32 | SHOULD | Instrumentation and observability | Metric instrument names, descriptions, and units SHOULD follow OpenTelemetry semantic conventions and UCUM unit symbols. The default HTTP instrumentation SHOULD emit a request counter named 'http.client.request.count' (unit '{request}') and a latency histogram named 'http.client.request.duration' (unit 'ms'), tagged with method and either status code (success) or error type (failure). | +| OBS-33 | MUST | Instrumentation and observability | A monotonic counter MUST document that only non-negative increments are valid (negative deltas are undefined behaviour, the caller's responsibility), and the core instrument MUST NOT validate this on the hot path. A histogram MUST tolerate any input without throwing (the no-op discards it); handling of non-finite values (NaN/±Infinity) is delegated to concrete adapters. | +| OBS-34 | MUST | Instrumentation and observability | HTTP logging granularity MUST be selectable across at least three levels — none, headers-only, and headers-plus-body — defaulting to none (logging off unless explicitly opted in). At none, request/response log events MUST NOT be emitted; body capture MUST occur only at the body level. Span lifecycle AND metric recording (request counter + latency histogram) run on every request independent of the log level, so 'none' silences log events without disabling tracing or metrics. | +| OBS-35 | SHOULD | Instrumentation and observability | A log-level value SHOULD be resolvable from layered configuration (explicit override -> environment variable -> normalized system property -> default) with tolerant parsing: case-insensitive and whitespace-trimmed matching against the level names, falling back to a caller-supplied default (itself defaulting to 'none') when the key is absent, empty, or unrecognised. The SDK MUST NOT bake in a default config key name. | +| OBS-36 | MUST | Instrumentation and observability | Under body logging, body capture MUST be bounded to a configurable preview size (reference default 8 KiB) and MUST NOT buffer the whole body: a body larger than the cap MUST still stream in full to the caller (replay captured prefix then continue from the live tail) while only the preview occupies memory. Logged body-size/preview fields therefore describe the captured preview, not necessarily the full body. | +| OBS-37 | SHOULD | Instrumentation and observability | For unknown-length (streaming/chunked) response bodies, the async logging path SHOULD skip body capture entirely and stream the body unwrapped, so a slow or idle producer cannot block the completion thread. Reimplementations SHOULD prefer headers-only logging for large downloads, SSE, and unknown-size encodings. | +| OBS-38 | SHOULD | Instrumentation and observability | A captured body preview SHOULD be rendered charset-aware for text (decoding with the media type's declared charset, falling back to UTF-8 when absent/unknown) and binary-safe for non-text payloads (emitting a size-only marker such as '[binary N bytes captured]' instead of decoding). Decoding MUST NOT throw — malformed/truncated input yields replacement characters rather than an error. Empty input yields an empty preview. | +| OBS-39 | MUST | Instrumentation and observability | The set of emitted structured event names and field keys MUST be stable and predictable: at minimum request/response events named 'http.request' and 'http.response', carrying keys 'http.request.method', 'url.full' (redacted), 'http.response.status_code', 'http.response.duration_ms', and content-length/header fields; a failure emits an 'http.response' event with 'error.type' and the throwable cause attached. The logged 'url.full' MUST always be the redacted URL. | +| OBS-40 | SHOULD | Instrumentation and observability | A once-per-logger diagnostic SHOULD warn when a caller sets a per-event field colliding with the reserved 'event' key (whose value is then dropped in favour of the event tag), throttled to at most one emission per logger and gated on the verbose level being enabled so it never costs anything on the hot path when disabled. Ambient 'event' keys from global context or diagnostic context defer silently and MUST NOT be warned about. | +| CFG-1 | MUST | Configuration and utilities | A configuration value lookup for a key MUST resolve in strict precedence order: (1) an explicit override registered for that exact key, (2) the environment-variable source queried by the exact key name, (3) the system-property source queried by the NORMALIZED key name, (4) the caller-supplied default (which MAY be null/absent). | +| CFG-2 | MUST | Configuration and utilities | An environment-variable value that is present but empty MUST be treated as absent, so the lookup falls through to the system-property layer (and ultimately the default) rather than resolving to the empty string. | +| CFG-3 | MUST | Configuration and utilities | The system-property layer MUST be queried under a normalized key derived from the lookup name by lowercasing it and replacing every underscore with a dot (e.g. MAX_RETRY_ATTEMPTS -> max.retry.attempts). The override and environment layers MUST use the original (un-normalized) name. | +| CFG-4 | MUST | Configuration and utilities | A separate raw property accessor MUST look up the system-property source by the EXACT name given, WITHOUT the env->property normalization, so keys that live only in camelCase system-property form (e.g. https.proxyHost, http.nonProxyHosts) resolve with their casing preserved. | +| CFG-5 | MUST | Configuration and utilities | Typed accessors MUST NOT throw on missing or unparseable values: the integer accessor returns the caller default when the resolved string is absent or not a valid integer; the duration accessor returns the default on any parse failure. Negative integers are valid values and MUST be returned as-is. | +| CFG-6 | MUST | Configuration and utilities | The boolean accessor MUST be strict: only the case-insensitive tokens "true" and "false" are recognized. Any other value — including "1", "0", "yes", "no", "on", "off" — MUST fall through to the caller default. | +| CFG-7 | MUST | Configuration and utilities | The duration accessor MUST accept: ISO-8601 durations (leading 'P'/'p', e.g. PT5S, P1D); shorthand with units ms, s, m, h, d (case-insensitive); and a bare number, which MUST be interpreted as MILLISECONDS. A negative duration (ISO or shorthand) MUST be rejected (return the default), and an unknown unit MUST return the default. | +| CFG-8 | MUST | Configuration and utilities | A built configuration MUST be immutable and safe to share across threads without external synchronization: the override map MUST be defensively copied at build time so that later builder mutation cannot alter an already-built instance. | +| CFG-9 | MUST | Configuration and utilities | Deriving a reconfigured configuration MUST be copy-on-write: it produces a NEW instance with the mutator applied while leaving the receiver completely unchanged. The override map MUST be copied before the mutator runs (added/replaced/removed overrides never leak back to the receiver), while the environment and system-property source seams MUST be inherited by reference (shared, not copied) unless the mutator explicitly replaces them. | +| CFG-10 | MUST | Configuration and utilities | Removing an override for a key MUST drop only the override layer: a subsequent lookup for that key MUST fall through to the environment, system-property, and default layers exactly as if the override had never existed. Removal MUST NOT force the key to resolve to null, and removing a key that has no override MUST be a harmless no-op. | +| CFG-11 | MUST | Configuration and utilities | The environment source and the system-property source MUST be substitutable seams (injectable functions from key name to optional string), so conformance tests and applications can supply hermetic lookups without touching the real process environment. The production defaults MUST delegate to the platform environment and platform system properties respectively. | +| CFG-12 | SHOULD | Configuration and utilities | Configuration builders SHOULD be usable single-threaded only (no internal synchronization required); the immutability guarantee applies to the built configuration, not to an in-progress builder. | +| CFG-13 | SHOULD | Configuration and utilities | A process-wide global configuration slot SHOULD be provided with last-write-wins replacement and safe publication (a reader always observes a fully-constructed, most-recently-set instance). It MUST default to an empty configuration (no overrides, platform-backed seams). | +| CFG-14 | SHOULD | Configuration and utilities | The subsystem SHOULD expose stable well-known key constants for the retry-attempt cap, SDK log level, and standard proxy variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), so consumers reference canonical names rather than string literals. | +| CFG-15 | MUST | Configuration and utilities | The time abstraction MUST be an injectable seam exposing three operations: current wall-clock instant, a monotonic elapsed-time counter, and a blocking interruptible sleep. A shared default backed by the platform clock MUST be provided. Time-dependent logic (retry backoff, credential expiry, redirect timing) SHOULD route through this seam rather than calling the platform clock directly, so tests can drive time deterministically with a fake. | +| CFG-16 | MUST | Configuration and utilities | The monotonic counter MUST be non-decreasing and used only for measuring elapsed durations between two of its own readings; its absolute value is not meaningful. The wall-clock reading MAY move backwards (e.g. on OS clock adjustment) and MUST NOT be used for elapsed-time measurement. | +| CFG-17 | MUST | Configuration and utilities | sleep MUST reject a negative duration with an argument error, MUST allow a zero duration (returning promptly, possibly yielding), and MUST honor cooperative cancellation/interruption: when interrupted mid-sleep it MUST re-assert the thread's interrupt/cancellation status before propagating the interruption so downstream handlers observe the cancelled state. Implementations SHOULD preserve sub-millisecond precision where the platform allows. | +| CFG-18 | SHOULD | Configuration and utilities | The async layer SHOULD provide a scheduled non-blocking delay: an operation that yields a future completing (with an empty/void value) after a given non-negative duration elapses on a provided scheduler, WITHOUT blocking a thread. A zero delay MUST complete the future immediately; a negative delay MUST be rejected; cancelling the returned future MUST cancel the underlying scheduled task so the scheduler thread is not held. | +| CFG-19 | SHOULD | Configuration and utilities | When surfacing the cause of a failed asynchronous operation, the subsystem SHOULD unwrap the platform's async-completion wrapper exceptions (the equivalents of a completion/execution wrapper) to expose the original underlying throwable, terminating on the first non-wrapper, a null cause, or a detected cycle. A non-wrapper throwable MUST be returned unchanged. | +| CFG-20 | SHOULD | Configuration and utilities | The subsystem SHOULD provide an interruptible-task future: running a task on an executor such that cancelling with the 'interrupt' flag interrupts the worker thread currently running the task, while cancelling without it cancels without interrupting. A task still queued (not yet started) or already finished MUST NOT be interrupted, and the worker's interrupt/cancel state MUST be cleared before it returns to its pool so a pooled thread is never poisoned by a stale interrupt aimed at a completed call. Rejected submission (saturated/shut-down executor) MUST be delivered through the returned future, never thrown synchronously. | +| CFG-21 | MUST | Configuration and utilities | When an interruptible-task future has already been cancelled and the task nonetheless produced a result value that is a closeable resource, that result MUST be closed on the discard path (best-effort, swallowing close failures) rather than leaked. The best-effort close helper MUST be null-safe. | +| CFG-22 | MUST | Configuration and utilities | The proxy model MUST be immutable and carry: the proxy protocol type (HTTP, SOCKS4, SOCKS5), the socket address, an ordered list of non-proxy host glob patterns, optional username/password credentials, an optional challenge-handler slot, and an explicit bypass-all flag. Its string rendering MUST mask credentials (never emit username/password in cleartext). | +| CFG-23 | MUST | Configuration and utilities | The host-bypass decision MUST short-circuit to true when the bypass-all flag is set; otherwise it MUST return true iff the host matches any configured non-proxy glob pattern. Glob-to-pattern conversion MUST treat '*' as 'match any run of characters', '?' as 'match one character', escape all regex metacharacters, require a FULL-string match, and match case-insensitively (host names are case-insensitive). Patterns SHOULD be compiled once at construction, not per lookup. | +| CFG-24 | MUST | Configuration and utilities | Resolving proxy options from configuration MUST follow this source precedence and MUST NOT throw on any malformed input (proxies are optional; invalid config yields null and a warning log): (1) the system-property layer first — host is https.proxyHost preferred over http.proxyHost, and the port MUST be taken from the SAME layer as the chosen host (https host -> https.proxyPort, http host -> http.proxyPort); credentials are read ONLY from https.proxyUser/https.proxyPassword (there is no http.* credential fallback); (2) if no system-property host is set, the environment URL HTTPS_PROXY preferred over HTTP_PROXY, parsed as scheme://user:pass@host:port. If no proxy is configured, or configuration is invalid, resolution MUST return null. | +| CFG-25 | MUST | Configuration and utilities | The proxy port MUST be explicit and within 0..65535; a missing, non-numeric, or out-of-range port MUST cause resolution to yield null (with a warning) rather than guessing a default port. When parsing a proxy URL, an absent port MUST be treated as invalid (no defaulting to 80/443), because the request's target scheme is unrelated to the proxy's listen port. | +| CFG-26 | MUST | Configuration and utilities | The effective non-proxy host list MUST be resolved with the system property (pipe-separated) winning over the environment variable (comma-separated). Both forms MUST honor a backslash escape preceding the literal separator (so an escaped separator becomes a literal in the emitted token) and MUST trim tokens. Fragments that are empty are dropped; the observable order is split -> drop empty (before unescape/trim) -> unescape -> trim, so a whitespace-only fragment is retained as an empty token rather than removed. | +| CFG-27 | MUST | Configuration and utilities | When the resolved non-proxy configuration is exactly a single bare '*' wildcard, it MUST be interpreted as bypass-all: proxy resolution returns null so the caller routes directly. This bypass-all semantics MUST be represented by the explicit bypass-all flag/return, NOT by placing '*' as a literal entry in the non-proxy host list (a '*' inside a multi-entry list is a normal any-host glob, not the bypass-all sentinel). | +| CFG-28 | MAY | Configuration and utilities | A convenience resolver MAY read proxy options from the process-wide global configuration, but the environment MUST be consulted only when a resolver is explicitly invoked — nothing may read proxy configuration implicitly at construction/startup. | +| CFG-29 | MUST | Configuration and utilities | RFC 1123 date FORMATTING MUST emit the canonical HTTP-date form with a zero-padded two-digit day-of-month and a literal 'GMT' zone, rendering the instant in UTC (e.g. 'Sun, 06 Nov 1994 08:49:37 GMT'). A single-digit day MUST be padded (06, not 6). | +| CFG-30 | MUST | Configuration and utilities | RFC 1123 date PARSING MUST be tolerant in these specific ways: month names are case-insensitive; the zone token accepts 'GMT', 'UTC', '+0000', and '+00:00' (all normalized to the zero offset); and the leading weekday token is informational only and MUST NOT be validated against the actual date (a weekday inconsistent with the date is accepted; it is stripped, not parsed). Parsing MUST return an instant. | +| CFG-31 | MUST | Configuration and utilities | RFC 1123 parsing MUST be strict on the day-of-month-onward grammar: blank/empty input MUST fail with a parse error, and a malformed header missing the comma after the weekday MUST fail (the weekday strip only applies to the well-formed 'Xxx, ' three-letter-plus-comma prefix). | +| CFG-32 | MUST | Configuration and utilities | The non-blocking UUID generator MUST produce type-4 (random) UUIDs with the correct RFC 4122 layout: the version field set to 4 and the variant set to the IETF variant (high bits '10'). It MUST be usable concurrently from multiple threads without shared mutable state. It MUST use a non-blocking randomness source (per-thread PRNG), and callers MUST treat the output as NON-cryptographic (suitable for request/trace IDs, not secrets). | +| CFG-33 | MUST | Configuration and utilities | Deep value-equality helpers MUST compare values by content: two arrays of the same kind are compared element-by-element (object arrays recurse so nested and multi-dimensional arrays compare structurally; primitive arrays compare by element value), while non-array values fall back to ordinary equality. Both helpers MUST be null-safe (two nulls are equal; null hashes to zero), and equals and hashCode MUST be mutually consistent (content-equal values hash equal). | +| CFG-34 | MUST | Configuration and utilities | Deep value equality MUST follow floating-point array semantics where NaN compares EQUAL to NaN and +0.0 compares UNEQUAL to -0.0 (for both primitive and boxed float/double arrays), and hashing MUST match that equality. An object array and a primitive array with the same numeric values MUST NOT be considered equal (distinct array kinds). | +| CFG-35 | SHOULD | Configuration and utilities | A shared retryability classifier SHOULD exist and treat these HTTP status codes as retryable: 408, 429, and all 5xx EXCEPT 501 and 505; and SHOULD treat a throwable as retryable iff it or any throwable in its cause chain is an IO/timeout error. Cause-chain traversal MUST be cycle-safe (terminate on a self-referential chain). Where the classifier is implemented, this exact status-code set is a hard contract so exception construction and the retry policy agree. | +| CFG-36 | SHOULD | Configuration and utilities | A static build/runtime descriptor SHOULD expose the SDK version and host runtime identity (runtime version, vendor, OS name) resolved once at load time, each falling back to a non-blank 'unknown' when unavailable, and SHOULD provide a default ordered identity-token list (SDK token then runtime token) for User-Agent-style composition. Every token MUST be non-blank so joined identity strings are never malformed. | +| CFG-37 | MUST | Configuration and utilities | Passing a null/absent required argument to a mutating configuration operation (override key or value, source function, derive mutator, or the global-config setter) MUST fail fast with an error rather than storing a null. Optional slots that are documented as nullable (proxy credentials, challenge handler, lookup default) MAY be null. | +| CFG-38 | MUST | Configuration and utilities | The typed accessors (integer, boolean, duration) MUST resolve the raw value through the SAME layered lookup as the string accessor (override -> environment -> normalized system property) before parsing; they MUST NOT read only the override map or skip the env/property layers. Only when the layered lookup yields no value does the caller-supplied typed default apply. | +| TRANSPORT-1 | MUST | Transport adapter conformance contract | An SDK-managed (builder-constructed) transport MUST disable the native client's automatic redirect following, so the SDK pipeline's redirect step is the single redirect authority. The default of the follow-redirects knob MUST be off. | +| TRANSPORT-2 | MUST | Transport adapter conformance contract | Where the native client has a built-in connection-failure/automatic retry feature, an SDK-managed transport MUST disable it, leaving the SDK pipeline as the single retry authority. (java.net.http has no such feature to disable.) | +| TRANSPORT-3 | MUST | Transport adapter conformance contract | On the synchronous send path a genuine caller-initiated cancellation MUST surface as a terminal, non-retryable interrupt-shaped IOException with the runtime's cancellation signal preserved for the caller; it MUST NOT be repackaged as the retryable transport-failure exception. Discrimination MUST be done out-of-band (the runtime's cancellation state, e.g. the JVM thread interrupt flag), not by matching exception messages. | +| TRANSPORT-4 | MUST | Transport adapter conformance contract | A read/response timeout MUST be classified as a RETRYABLE transport failure (the canonical NetworkException), and MUST NOT set the caller's cancellation/interrupt flag, even when the underlying runtime represents a timeout with the same exception family as an interrupt. | +| TRANSPORT-5 | MUST | Transport adapter conformance contract | When a per-call timeout override is supplied it MUST be applied to that single call, overriding the transport's configured default for that call only and leaving the shared native client's configuration untouched; a null override MUST leave the configured default in force. | +| TRANSPORT-6 | SHOULD | Transport adapter conformance contract | A transport SHOULD NOT let a positive per-call timeout be silently reduced to zero (or 'no timeout') by unit truncation. Where the native timeout API expresses deadlines in a coarser unit than the requested duration AND treats zero as 'no timeout' (unbounded), a positive sub-resolution duration MUST be clamped up to the smallest finite deadline the native client can represent rather than truncated to zero, so a tight budget does not silently flip to unbounded. | +| TRANSPORT-7 | MUST | Transport adapter conformance contract | Cancelling the async response future MUST propagate cancellation into the in-flight native exchange so its connection/resources are released promptly instead of running to completion unobserved. | +| TRANSPORT-8 | MUST | Transport adapter conformance contract | Where the native client can surface a cancellation that originates inside it (e.g. an internal cancel-all or an interceptor-driven cancel) while the SDK future is still live, that cancellation MUST complete the future with a terminal, non-retryable cancellation-shaped exception, NOT the retryable transport-failure exception; a genuine timeout on the same path MUST still complete with the retryable type. (The reference OkHttp transport implements this discrimination; the java.net.http transport has no internal-cancel path to distinguish.) | +| TRANSPORT-9 | MUST | Transport adapter conformance contract | If a native response is delivered after the SDK future has already been completed or cancelled (the adaptation race), the adapted response MUST be closed so its connection is returned to the pool and not leaked. | +| TRANSPORT-10 | MUST | Transport adapter conformance contract | The caller's explicit request Content-Type header MUST remain authoritative on the wire and MUST NOT be overwritten by any body-derived / auto-stamped media type. A body-derived Content-Type MUST be emitted only when the caller set none (matched case-insensitively). | +| TRANSPORT-11 | MUST | Transport adapter conformance contract | Headers the native client computes from the body/connection (at minimum Content-Length, Host, Transfer-Encoding; plus any the native client rejects outright, e.g. Connection/Expect/Upgrade on java.net.http) MUST be dropped from the outbound request before dispatch, because the transport owns these framing headers and a caller-set value would be rejected or produce a wire-format mismatch. The transport SHOULD additionally log each such drop at a verbose level naming the header. (The exact drop set is transport-specific: OkHttp deliberately does NOT drop Connection, since it honours a Connection: close hint.) | +| TRANSPORT-12 | MUST | Transport adapter conformance contract | A header that is valid at the SDK model layer but rejected by the native client's stricter wire grammar MUST be dropped for that header only; the resulting native exception MUST NOT escape the send contract. The rest of the headers and the body MUST still be dispatched, on both sync and async paths. | +| TRANSPORT-13 | SHOULD | Transport adapter conformance contract | A transport SHOULD expose a configurable policy for how model-valid-but-transport-rejected header drops are logged, with at least three modes: log every drop loudly; log the first drop per header name loudly and the rest quietly (default); log all quietly. Where the per-name dedup mode is implemented it MUST be case-insensitive and MUST be bounded so an attacker synthesising unbounded distinct names cannot grow it without limit. | +| TRANSPORT-14 | MUST | Transport adapter conformance contract | Inbound (response) headers MUST be copied leniently enough that a single malformed header does not fail the whole response: a control byte in a field VALUE, or a control/non-ASCII byte in a field NAME, MUST cause only that offending header to be dropped (logged at verbose) while the body and the remaining headers are still delivered. A transport SHOULD preserve a non-ASCII / obs-text byte in a field VALUE rather than stripping it (RFC 7230 permits obs-text in values, e.g. a Latin-1 Content-Disposition filename); the reference transports preserve it. | +| TRANSPORT-15 | MUST | Transport adapter conformance contract | close() MUST be ownership-aware: it releases ONLY resources the transport itself created (native client, its dispatcher/executor, connection pool, cache, any SDK-created executor). A caller-supplied ('bring your own') native client and its resources MUST NOT be shut down or mutated by the transport, so the caller may keep using it after the transport is closed. | +| TRANSPORT-16 | MUST | Transport adapter conformance contract | close() MUST be idempotent (repeat calls are safe no-ops after the first) and MUST NOT block on native shutdown in a way that discards the caller's cancellation/interrupt state; it uses non-blocking shutdown semantics (no unbounded await). | +| TRANSPORT-17 | MUST | Transport adapter conformance contract | A non-replayable (single-use) request body MUST be written to the wire exactly once; the transport MUST prevent the native client from re-writing it (e.g. by reporting the body as one-shot) and MUST NOT itself trigger a second write. A replayable body MAY be re-written. | +| TRANSPORT-18 | MUST | Transport adapter conformance contract | When the native body API drives writes through a re-subscribable producer (so a native internal resend — proxy-auth 407, protocol GOAWAY replay — re-reads the body), the transport MUST make each subscription produce identical bytes. It MUST require or ensure a replayable source: a non-replayable body is buffered once into a replayable copy, and if that buffering fails mid-write the send MUST fail with the transport-failure (IOException) type rather than shipping a truncated/empty body. | +| TRANSPORT-19 | SHOULD | Transport adapter conformance contract | When a streaming-body subscription is acquired but then abandoned (connect failure, cancellation before the body is sent), the transport SHOULD cancel/unblock its producer so no writer thread or file handle is stranded; the teardown MUST be idempotent (the native client may close the stream more than once). | +| TRANSPORT-20 | MUST | Transport adapter conformance contract | Any transport failure that produced no HTTP response — connection refused, DNS failure, TLS handshake failure, peer reset, connect/read timeout — MUST surface as the SDK's canonical retryable transport-failure exception, which MUST be a subtype of the platform IOException so existing catch(IOException) sites keep matching, and MUST report itself as retryable. | +| TRANSPORT-21 | MUST | Transport adapter conformance contract | On the async path, a failure that occurs on the caller's thread before dispatch (request adaptation rejecting a request, a synchronous dispatch rejection, or an unexpected adapter bug) MUST be delivered through the returned future (completed exceptionally), NOT thrown synchronously to the caller. Only truly fatal runtime errors (e.g. OOM) may propagate synchronously. | +| TRANSPORT-22 | MUST | Transport adapter conformance contract | If adapting a live native response into the SDK response throws at any point after the native response (and its socket) are live, the transport MUST close the native response before the throwable propagates, so the connection is not leaked. This guard MUST cover both the sync and async response paths. | +| TRANSPORT-23 | MUST | Transport adapter conformance contract | The async send MUST NOT complete its future with a null response on success; a transport that has no response MUST complete the future exceptionally instead. | +| TRANSPORT-24 | MUST | Transport adapter conformance contract | The response status code MUST be mapped totally: any code the server returns, including vendor/non-standard codes (e.g. nginx 499, Cloudflare 520-526/530), MUST be surfaced faithfully rather than rejected, and such a response (and its body) MUST remain readable and closeable. | +| TRANSPORT-25 | MUST | Transport adapter conformance contract | The response body MUST be exposed as a lazily-read stream, not pre-buffered by the transport, and closing the SDK response MUST cascade to close the native body and release the underlying connection back to the pool. The caller owns closing the response. | +| TRANSPORT-26 | MUST | Transport adapter conformance contract | A body-less request MUST be valid for any HTTP method the model permits. Where the native client rejects a null body for a method it treats as body-requiring (e.g. POST/PUT/PATCH), the transport MUST substitute a zero-length body so the request dispatches (with Content-Length: 0) instead of failing; for body-forbidden methods (GET/HEAD/TRACE/CONNECT) it MUST NOT attach a body. | +| TRANSPORT-27 | SHOULD | Transport adapter conformance contract | An unparseable or absent inbound Content-Type SHOULD be downgraded to 'no media type' rather than failing the response; an absent/invalid Content-Length SHOULD map to the SDK's unknown-length sentinel (-1). | +| TRANSPORT-28 | SHOULD | Transport adapter conformance contract | A transport SHOULD stream a file-backed request body directly from the file (honoring any start position and byte count) on a zero-copy path where the native I/O stack supports it, and MUST treat a file body as replayable (re-openable) so it can be re-sent on a native/pipeline resend. | +| TRANSPORT-29 | MUST | Transport adapter conformance contract | A transport instance MUST be safe for concurrent send calls from multiple threads and MUST be effectively immutable after construction; all per-request state MUST be confined to local scope or the returned response graph (no shared mutable per-call state). | +| TRANSPORT-30 | SHOULD | Transport adapter conformance contract | When the SDK's proxy configuration carries a feature the native client cannot honor, the transport SHOULD make the limitation discoverable rather than silently misbehaving, and MUST NOT leak credentials. Specifically: a custom (non-Basic) proxy challenge handler SHOULD be surfaced with a WARN and proxy auth SHOULD fall back to Basic from username/password. Proxy credentials MUST NOT be logged, and MUST NOT be answered to an origin-server (401) challenge — only to a matching proxy (407) challenge. | +| ASYNC-1 | MUST | Asynchronous runtime adapter contract | The async transport contract is a single-value completion future that yields exactly one Response on success or completes with exactly one failure. On the success path the future MUST deliver a non-null Response value; an implementation that has no response MUST complete via the failure channel rather than deliver a null/empty/absent value. | +| ASYNC-2 | MUST | Asynchronous runtime adapter contract | Every failure an adapter can detect while constructing the async operation MUST be delivered through the future's failure channel, never thrown synchronously from the method that promised a future. This includes request-adaptation errors and worker-pool rejection (a saturated/shut-down executor). | +| ASYNC-3 | MUST | Asynchronous runtime adapter contract | When an async operation is backed by a blocking task on a worker thread, cancellation MUST distinguish two modes: cancel-with-interrupt interrupts the worker currently executing the in-flight task, while cancel-without-interrupt cancels the logical operation without interrupting the worker (a blocking call that ignores interrupts then runs to completion in the background). A task still queued (not yet started) or already finished MUST NOT be interrupted. | +| ASYNC-4 | MUST | Asynchronous runtime adapter contract | Interrupt delivery MUST be ordered so a stale interrupt cannot poison a pooled thread: the cancel path publishes an 'interrupt in flight' marker BEFORE reading/targeting the worker thread, the worker's return-to-pool step blocks until that marker clears, and after the task ends the worker clears its own interrupt flag before it is reused. A worker that has already returned to its pool MUST NOT receive an interrupt aimed at a completed call. | +| ASYNC-5 | MUST | Asynchronous runtime adapter contract | If a worker computes a closeable result (e.g. a Response holding an open body or pooled connection) but the future was already terminated (cancelled or otherwise completed) so the value can never be delivered to a caller, the adapter MUST close that orphaned value exactly once. Whoever loses the produce/terminate race performs the close. | +| ASYNC-6 | MUST | Asynchronous runtime adapter contract | Cancellation MUST propagate bidirectionally across each ecosystem adapter: cancelling the runtime-native primitive (reactive subscription, event-loop promise, coroutine/job) MUST cancel the underlying canonical future, and cancelling the canonical future (via the sync-bridge or transport hook) MUST reach the runtime primitive / native transport call. | +| ASYNC-7 | SHOULD | Asynchronous runtime adapter contract | Each adapter chooses whether its native cancellation maps to interrupt-mode or non-interrupt-mode cancellation, and that choice determines whether an in-flight blocking call is actually aborted. A port SHOULD preserve, and document per adapter, whether cancelling through a given runtime aborts a blocking transport or lets it run to completion, so cross-runtime cancellation behavior is predictable. | +| ASYNC-8 | SHOULD | Asynchronous runtime adapter contract | Adapters that move work or callbacks onto a thread other than the caller's SHOULD propagate the caller's diagnostic logging context (trace/span identifiers) across the hop — capturing it on the boundary thread and reinstating it on the executing/callback thread — so log events emitted after the hop retain correlation with the originating call. This is an observability guarantee, not a functional one: an adapter that omits it still executes exchanges correctly. | +| ASYNC-9 | MUST | Asynchronous runtime adapter contract | When an adapter reinstates a captured logging context on an executing or callback thread, it MUST first save that thread's prior context, install the captured context only for the duration of the work, and restore the prior context afterward — including when the work throws — so a reused/pooled thread's own logging context is never clobbered. | +| ASYNC-10 | MUST | Asynchronous runtime adapter contract | When an adapter propagates logging context, capture MUST occur at the point that identifies the logical caller of the execution — per-subscription for cold/reusable stream or promise objects, and per-task-submission for executor decorators — not at object-construction/assembly time. A reused async object MUST pick up the live context of each use rather than a stale snapshot from when it was assembled. | +| ASYNC-11 | MUST | Asynchronous runtime adapter contract | When an adapter propagates logging context, capture and restore MUST be safe when no logging-context backend is installed: an absent context captures as empty, and reinstating an empty context clears the target thread's context rather than raising an error. | +| ASYNC-12 | MUST | Asynchronous runtime adapter contract | On runtimes where a newly created worker does not inherit the spawning thread's logging context (e.g. lightweight threads or plain thread-local contexts), an adapter that propagates logging context MUST explicitly transfer it at the thread-creation boundary. This obligation is distinct from, and additional to, any guarantee the runtime provides for preserving a thread's own context across carrier hops. | +| ASYNC-13 | MUST | Asynchronous runtime adapter contract | When surfacing a failure to callers, adapters MUST unwrap the async framework's wrapper exceptions (the wrappers a completion/execution framework interposes around the real cause) down to the original cause, so typed failure handlers match the real exception rather than a wrapper. Unwrapping MUST terminate on the first non-wrapper cause, a null cause, or a detected cycle. | +| ASYNC-14 | MUST | Asynchronous runtime adapter contract | An async→sync blocking bridge MUST honor thread interruption while awaiting: on interruption it MUST restore the interrupt flag, cancel the in-flight future, and throw an interrupted-I/O failure; and it MUST unwrap execution-wrapper exceptions so blocking callers see the original failure. A future cancelled independently MUST surface its cancellation as-is (not remapped to an I/O error). | +| ASYNC-15 | MUST | Asynchronous runtime adapter contract | An adapter that owns an executor or background threads MUST expose a close/dispose operation that is (a) idempotent — repeated calls are safe and only the first performs shutdown/side-effects; (b) ownership-aware — it releases only SDK-owned resources and never shuts down a caller-supplied executor/client; and (c) interrupt-safe — it honors thread interruption on any blocking shutdown step. | +| ASYNC-16 | SHOULD | Asynchronous runtime adapter contract | An adapter that owns an executor SHOULD shut it down gracefully on close — stop accepting new work and wait for in-flight tasks to finish rather than interrupting them — escalating to a forceful shutdown only if the closing thread is itself interrupted. Callers needing eager abort of an in-flight blocking call use the interrupt / structured-cancellation path instead. | +| ASYNC-17 | SHOULD | Asynchronous runtime adapter contract | The async transport SPI SHOULD provide a no-op default close so lightweight/functional (single-abstract-method) implementations need not implement lifecycle management, while any implementation that owns resources overrides it to follow the idempotent/ownership-aware/interrupt-safe contract (ASYNC-15). Behavior of executeAsync after close is undefined. | +| ASYNC-18 | MUST | Asynchronous runtime adapter contract | The non-blocking scheduled-delay primitive used to insert async delays into a future chain MUST complete after the requested delay without blocking a thread, MUST complete immediately for a zero delay, MUST reject a negative delay, and cancelling the returned future MUST cancel the underlying scheduled task so no scheduler thread is held. | +| ASYNC-19 | MUST | Asynchronous runtime adapter contract | Every bridge and facade overload that accepts per-call request options MUST thread those options into the wrapped send so per-request overrides (timeout, retry budget, tags) survive the async boundary, rather than being dropped by the SPI's options-ignoring default overload. | +| ASYNC-20 | MUST | Asynchronous runtime adapter contract | Once a Response has been delivered to the caller through the future, cancelling that future MUST NOT close the Response body. The caller owns closing the Response even when discarding it. (This is distinct from ASYNC-5: the orphan-close obligation applies only to a value the future never delivered.) | +| ASYNC-21 | MUST | Asynchronous runtime adapter contract | An adapter exposing a streaming source (SSE) as a reactive stream MUST honor downstream backpressure by polling the source at most once per unit of demand (never eagerly), MUST complete the stream on end-of-source, MUST propagate a source exception as an error signal while NOT swallowing fatal errors, MUST NOT close the caller-owned source on any termination (complete/error/cancel), and MUST treat the underlying source as single-subscriber (a fresh source per subscription). | +| ASYNC-22 | MUST | Asynchronous runtime adapter contract | Async transport implementations MUST be safe for concurrent calls from multiple threads, with all per-call mutable state confined to the returned future's own completion graph (no shared mutable per-call state on the client). | +| XCUT-1 | MUST | Cross-cutting invariants and policies | A thread/task cancellation MUST be surfaced as a distinct, terminal, NON-retryable signal, kept separate from a timeout. On the reference (JVM) runtime the pattern is: catch the interrupt, re-assert the runtime's interrupt/cancellation state, then throw the I/O-family cancellation type (InterruptedIOException). A port MUST preserve the portable intent: propagate cancellation without losing the ambient cancellation flag, and never let a cancelled operation be automatically retried. | +| XCUT-2 | MUST | Cross-cutting invariants and policies | A read/response/connect TIMEOUT (a deadline elapsing) MUST be classified as a RETRYABLE transport failure, and MUST NOT set the cancellation flag. Timeout and cancellation MUST be told apart by the ambient cancellation state, not by matching an error message string, even when the underlying runtime represents both with the same exception type (and even when the timeout type is a SUBTYPE of the cancellation type — the timeout branch must be checked first to stay reachable). | +| XCUT-3 | MUST | Cross-cutting invariants and policies | Inter-attempt waits (retry backoff, timed sleeps) MUST be promptly cancellable: a pending wait MUST abort near-immediately when the operation is cancelled/interrupted, surface the cancellation signal (not a spurious timeout), and cancel any timer/future it armed. The reference implements the wait as a scheduled timer completing an awaitable future rather than a raw blocking sleep, so a virtual-thread carrier can unmount and no shared pool thread is monopolized for the delay; a port SHOULD preserve that non-pinning property where its runtime has an equivalent concern, but the normative requirement is prompt cancellation, not the specific mechanism. | +| XCUT-4 | MUST | Cross-cutting invariants and policies | The error taxonomy MUST have exactly two top-level branches: (a) protocol errors that carry a fully-received response (status, headers, body) and are raised as an unchecked/runtime error so callers are not forced to declare I/O failure for protocol outcomes; and (b) transport errors that carry NO response and belong to the runtime's I/O-error family so existing I/O catch sites keep matching. A transport error MUST report itself as always-retryable at the error level (the SDK treats 'no response reached the server' as transient). | +| XCUT-5 | MUST | Cross-cutting invariants and policies | The baked retryability flag of a protocol (status-carrying) error MUST be computed ONCE at construction from a SINGLE shared status classifier, never hardcoded per status subclass. That classifier MUST treat 408, 429, and all 5xx EXCEPT 501 and 505 as retryable, and every other status as not retryable. The stored flag MUST always agree with the live classifier for that code. NOTE: this baked flag is a queryable property of the error; the retry step's actual eligibility gate for a protocol error is the configured retryable-status set (see XCUT-7), which by default is a subset of this classifier. | +| XCUT-6 | MUST | Cross-cutting invariants and policies | A transport-family or custom error type that declares itself retryable via the retryability capability MUST be able to participate in retry decisions without editing the retry classifier — for such errors the classifier queries the capability (is-Retryable and the flag), not a concrete-type match. This open-capability path governs non-protocol errors; a protocol (status-carrying) error is handled by the separate rule in XCUT-7 and its baked flag is NOT what the retry step consults. | +| XCUT-7 | MUST | Cross-cutting invariants and policies | For a protocol (status-carrying) error, retry eligibility MUST be decided by a CONFIGURABLE retryable-status set, which is authoritative: the retry step consults this set (not the error's baked retryability flag), and the set MAY widen the built-in classification (retry a status the built-in classifier does not mark retryable) or narrow it. The default set is {408, 429, 500, 502, 503, 504}. The same set MUST also govern whether a freshly re-sent error-status response is re-classified as a failure for the next attempt. | +| XCUT-8 | MUST | Cross-cutting invariants and policies | The status-to-exception mapping factory MUST reject being asked to map a non-error status (1xx/2xx/3xx) — it MUST raise an argument error rather than fabricate a 'successful exception'. A convenience form MAY instead return an absent/null value for non-error statuses. | +| XCUT-9 | MUST | Cross-cutting invariants and policies | Any classification that walks an error's cause chain MUST be cycle-safe: it MUST track visited causes by reference identity and terminate on a self-referential or cyclic chain instead of looping forever. | +| XCUT-10 | MUST | Cross-cutting invariants and policies | Retry-SAFETY MUST be decided at the retry step, independently of retryability, and applied UNIFORMLY to both protocol and transport failures: (a) a request WITHOUT a body is retry-safe only if its method is in the configured idempotent-method set — a body-less non-idempotent request such as a bare POST MUST NOT be retried, EVEN when the failure is a transport error that never reached the server; (b) a request WITH a body is retry-safe only if that body is replayable — a single-use/streaming body MUST NOT be re-sent. The safety gate MUST NOT special-case transport errors: the method-idempotency and body-replayability checks apply to every retry candidate. | +| XCUT-11 | MUST | Cross-cutting invariants and policies | Components documented as shared/reusable across concurrent requests (pipeline steps, auth handlers, redactors, factories) MUST be safe for concurrent invocation. Per-call mutable state (attempt counters, deadlines, seen-URI sets) MUST live on the call's stack/local state, not on the shared instance, and any shared mutable state MUST be synchronized. | +| XCUT-12 | SHOULD | Cross-cutting invariants and policies | Hot-path reads of a credential/token cache SHOULD be wait-free (e.g. a volatile-published read that takes no lock while the cached value is still valid). Refresh SHOULD be single-flight — only one concurrent caller fetches an expiring token while the others reuse the result. Any lock guarding the refresh MUST be scoped to that cache (per-credential/per-step) so it never serializes UNRELATED in-flight requests or the global scheduler; holding that scoped lock across the (possibly blocking) token fetch to enforce single-flight is acceptable and intended. The concurrency primitive is non-normative (the reference uses a reentrant lock rather than an intrinsic monitor to avoid pinning carrier threads under virtual threads). | +| XCUT-13 | MUST | Cross-cutting invariants and policies | close()/shutdown() MUST be idempotent (latched so repeat calls are no-ops) and MUST NOT block on interrupt-sensitive waits — it uses non-blocking shutdown semantics and preserves the ambient interrupt/cancel flag as-is. | +| XCUT-14 | MUST | Cross-cutting invariants and policies | Every process/instance-lived map whose key space is influenced by callers or remote servers (context registries, per-nonce counters, and any similar cache) MUST be bounded by a hard cap and MUST drain back under the cap after each insert using a loop (not a single pre-insert check-then-evict), so a concurrent insert burst converges to the bound instead of overshooting permanently. Arbitrary-victim eviction is acceptable; the cap is a memory backstop and MUST NOT be relied on as the primary cleanup mechanism. | +| XCUT-15 | MUST | Cross-cutting invariants and policies | Public wire models (request, response, headers, media type, status, etc.) MUST be immutable after construction and safe to share across threads. Mutation MUST be expressed as producing a new instance (copy/builder/newBuilder). A model MUST NOT retain an alias to externally-mutable state that a post-construction mutation could use to alter it — either defensively copy an ingested mutable collection or hold an immutable collection type. | +| XCUT-16 | MUST | Cross-cutting invariants and policies | A credential MUST NOT be stamped onto a request over a non-secure (non-HTTPS) transport. The auth layer MUST reject (fail loudly) before any token fetch or header write when it is about to attach a credential and the scheme is not https (case-insensitive). The guard applies ONLY on the credential-attaching path — a deliberately credential-free re-issue (e.g. a marker-suppressed cross-origin redirect) MAY proceed over any scheme. | +| XCUT-17 | MUST | Cross-cutting invariants and policies | Redirect handling MUST enforce credential hygiene: (a) strip the Authorization header before EVERY redirect re-issue (even same-origin — re-stamping a known origin is the auth layer's job); (b) on a CROSS-ORIGIN redirect (judged against the ORIGINAL seed origin, not the previous hop) additionally strip origin-scoped credentials (Cookie, Proxy-Authorization) and ensure the caller's credential is NOT re-applied to the foreign host; (c) drop any userinfo (user:pass@) carried in the Location before re-issue; (d) reject an HTTPS-to-HTTP scheme downgrade by default, permitting it only via explicit opt-in (and logging the deviation). | +| XCUT-18 | MUST | Cross-cutting invariants and policies | Header names and outbound header values MUST be validated at the transport-agnostic model layer BEFORE reaching any transport. Names MUST reject all C0 control bytes (0x00-0x1F, including CR, LF, NUL, and HTAB) and DEL (0x7F). Outbound values MUST reject the same set EXCEPT horizontal tab (0x09), which is permitted. Both names and outbound values MUST reject non-ASCII bytes (>= 0x80). Inbound (response) header values MAY be validated leniently — non-ASCII/obs-text permitted — but MUST still reject control bytes (except HTAB) as defense-in-depth. | +| XCUT-19 | MUST | Cross-cutting invariants and policies | Logging/telemetry MUST redact secrets by default: (a) URL userinfo MUST ALWAYS be redacted (never allow-listed); (b) URL query-parameter values and key=value fragment tokens MUST be redacted unless the parameter name (case-insensitive) is in an explicit allow-list; (c) header logging MUST be default-deny — only an explicit allow-list of non-credential headers is emitted verbatim, all others are emitted as a redaction placeholder or omitted; (d) credential objects MUST NOT reveal their secret in string/serialized form; (e) full request/response BODY logging MUST be OFF by default. | +| XCUT-20 | MUST | Cross-cutting invariants and policies | Observability code paths (redaction, structured event emission, span/metric recording) MUST NEVER throw into the caller's request path. A failure to redact/render/emit MUST degrade gracefully (e.g. substitute a safe placeholder such as a malformed-URL marker, or emit a self-describing instrumentation-error event) and let the request proceed. | +| XCUT-21 | MUST | Cross-cutting invariants and policies | Any security-relevant random value (auth client nonces / cnonce, and similar unpredictability-dependent tokens) MUST be drawn from a cryptographically-strong PRNG with sufficient entropy (the reference uses >= 128 bits for the Digest cnonce), never a non-cryptographic RNG. | +| XCUT-22 | MUST | Cross-cutting invariants and policies | The SDK MUST close only resources it created. A caller-supplied (bring-your-own) transport client, executor, or connection pool MUST NOT be closed/shut down by the SDK; the caller owns its lifecycle and may keep using it after the SDK component is closed. Only SDK-constructed resources are released on close. | +| XCUT-23 | MUST | Cross-cutting invariants and policies | A pluggable single-implementation seam (the I/O provider, and any similar SPI) MUST resolve deterministically: an explicit install ALWAYS wins; otherwise the implementation is auto-discovered from the environment/classpath; and the presence of ZERO or MULTIPLE candidates with no explicit selection MUST fail loudly with an actionable error rather than silently pick one or no-op. Reads of the resolved provider MUST be safe under concurrency. | +| XCUT-24 | SHOULD | Cross-cutting invariants and policies | Diagnostic/preview reads of caller- or server-controlled payloads (error-body snapshots, request/response body log previews) MUST be byte-capped and SHOULD be non-consuming — a preview MUST NOT materialize an unbounded payload into memory and MUST NOT disturb the primary read path the consumer will use (e.g. by peeking rather than draining, or by capping the tap while the full body streams through). | +| NFR-1 | MUST | Non-functional requirements and quality bar | The core module MUST depend only on the language standard library, the language runtime, and a compile-time-only logging facade abstraction. It MUST NOT carry a runtime dependency on any concrete HTTP transport, serialization library, I/O implementation, or async framework. All such concrete capabilities are supplied by separate adapter units that depend on the core, never the reverse (dependency inversion). | +| NFR-2 | SHOULD | Non-functional requirements and quality bar | Each optional capability (each transport, serialization format, I/O backend, and async-runtime bridge) SHOULD be a separately installable unit that depends on the core plus at most one third-party library, so a consumer composes only the units it uses and incurs no cost for unused ones. | +| NFR-3 | SHOULD | Non-functional requirements and quality bar | The public API surface SHOULD be explicit and minimal: every exported declaration is deliberately marked public with a declared type, implementation details are kept non-exported, and each adapter unit keeps its public surface as small as its capability allows — a single entry point where the capability permits (the I/O, coroutine, and Netty adapters each export exactly one public type), and a small, cohesive cluster of public types where it genuinely needs more (the serde adapter exports its Serde implementation plus an object-mapper factory, response handlers, and a Tristate module). Nothing internal leaks into the public surface by default. | +| NFR-4 | SHOULD | Non-functional requirements and quality bar | The public API of every published unit SHOULD be captured in a checked-in, machine-comparable snapshot, and the build SHOULD fail on any drift between the current surface and that snapshot. An intentional API change is landed by regenerating and committing the snapshot in the same change; the regeneration tool MUST NOT be used to silence an unintentional break. Unpublished/sample/test-only units are excluded from snapshotting. | +| NFR-5 | SHOULD | Non-functional requirements and quality bar | The build SHOULD enforce a minimum aggregate line-coverage floor (currently 80%) computed across the library units, and this verification SHOULD be wired into the default build lifecycle so an ordinary build enforces it. Sample/runnable-example code, test-only build guards, and test fixtures are excluded from the aggregate so they neither inflate nor deflate the measured floor. | +| NFR-6 | SHOULD | Non-functional requirements and quality bar | Compiler warnings SHOULD be treated as errors across every unit, including deprecation warnings, so that no warning can accumulate silently. | +| NFR-7 | SHOULD | Non-functional requirements and quality bar | The build SHOULD run automated style/lint and static-analysis checks with findings treated as fatal (a nonzero issue budget fails the build). Where a specific analyzer cannot run on a given unit's toolchain, disabling it SHOULD be a narrowly-scoped, documented exception carrying explicit re-enable conditions, not a silent global relaxation. | +| NFR-8 | MUST | Non-functional requirements and quality bar | In target ecosystems that support whole-program dead-code elimination / tree-shaking / minification, the SDK MUST ship the keep/retain configuration a downstream shrinker needs so its reflectively-reached and runtime-wired surface survives shrinking. That configuration MUST cover the runtime-wired SPI seams (I/O provider, transport clients, serde) and the immutable models and reflectively-bound types (request/response models, the Tristate type, and the reflective metadata that serializers read). In ecosystems without such a build step this requirement does not apply. | +| NFR-9 | SHOULD | Non-functional requirements and quality bar | The shipped shrinker keep-configuration SHOULD be guarded by an automated regression check, wired into the default build, that shrinks a real consumer of the SDK using only the shipped rules and runs it end-to-end against a live round-trip, failing the build if any runtime-wired or reflectively-reached surface is stripped. The guard SHOULD also assert that every shipped rule file is present so a dropped rule fails loudly rather than silently reducing protection. | +| NFR-10 | MUST | Non-functional requirements and quality bar | The SDK MUST declare a lowest-supported-runtime floor and target it for all general-purpose units. A capability that genuinely requires a newer runtime MUST be isolated into its own unit that declares the higher floor explicitly; that unit MUST NOT be a hard dependency of the general-purpose core. No produced artifact may reference runtime/stdlib APIs that are absent on the floor it declares — the emitted-artifact target and the visible-API level must agree, not just the compiler toolchain used. | +| NFR-11 | SHOULD | Non-functional requirements and quality bar | The core SHOULD be concurrency-model agnostic: it exposes plain blocking operations that are correct on any scheduler and leaks no async-framework types (coroutines, reactive publishers) into the core public surface. Shared mutable state is guarded for safe concurrent access, and the synchronization primitives used SHOULD avoid pinning or blocking lightweight scheduler threads (e.g. virtual/green threads, cooperative async runtimes) where the target runtime distinguishes them. | +| NFR-12 | SHOULD | Non-functional requirements and quality bar | Build artifacts SHOULD be reproducible: identical source inputs SHOULD yield byte-for-byte identical output artifacts (e.g. normalized/stripped embedded timestamps and deterministic entry ordering). | +| NFR-13 | SHOULD | Non-functional requirements and quality bar | Every source file SHOULD carry the project's license/SPDX header block. | +| NFR-14 | SHOULD | Non-functional requirements and quality bar | Dependency versions, plugin/tool versions, and project coordinates SHOULD live in a single source of truth rather than being restated per unit, so a version, tool, or coordinate bump is ideally a one-line edit that applies uniformly. | +| NFR-15 | SHOULD | Non-functional requirements and quality bar | Published artifacts SHOULD embed self-identifying version metadata that the SDK can resolve at runtime, so runtime-emitted identifiers (e.g. a User-Agent) report the real version rather than an 'unknown' placeholder fallback. | +| NFR-16 | SHOULD | Non-functional requirements and quality bar | Published artifacts SHOULD be cryptographically signed for provenance, with signing enforced on the release/CI path and made gracefully optional in local builds that lack signing keys, so consumers can verify authenticity without blocking day-to-day development. | +| NFR-17 | MUST | Non-functional requirements and quality bar | The quality gates that back the requirements above (compatibility snapshot, coverage floor, warnings-as-errors, lint/static-analysis, shrink-survival where applicable, and runtime-floor checks) MUST be enforced automatically and be blocking — failing the standard build/CI rather than being advisory reports a human can ignore. | From e580663131514c9e28bcbd0dd7e0a36a7691a282 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 9 Jul 2026 01:02:02 +0300 Subject: [PATCH 2/2] docs: specify planned capabilities and make the platform philosophy explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the product specification so it states the toolkit's platform stance as first-class principles and captures the capabilities it does not yet provide. - Add two architectural principles. The public API is platform surface for external consumers — generated clients, downstream SDKs, and applications that build on top of the core — so a primitive with no in-core caller is not dead code, and shipped surface must carry consumer-facing discoverability (SEAM-31). Interchangeable implementations of a seam must not diverge in observable results for core-owned behavior, differing only in performance and dependencies (SEAM-32). - Add a "Planned Capabilities" section defining the contract each capability must satisfy when built, its core-versus-adapter placement, and its opt-in discipline: response decompression and Accept-Encoding negotiation, a reference observability adapter, an OAuth2 token-provider adapter, a challenge-driving authentication step, an optional client-side rate limiter, a transfer-progress primitive, and an opt-in asynchronous redirect step. These are marked not yet implemented and are excluded from the current-behavior requirement index until they ship. - Correct the front matter: remove the reference to a nonexistent companion document, complete the requirement-prefix legend, and index the new current-behavior requirements. --- docs/product-spec.md | 68 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/product-spec.md b/docs/product-spec.md index 1efe320..fdcb29c 100644 --- a/docs/product-spec.md +++ b/docs/product-spec.md @@ -6,9 +6,9 @@ **How to read this document.** Requirements use the RFC 2119 keywords **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** with their standard meanings. A **MUST** is a correctness or safety invariant; a port that violates it is non-conforming. A **SHOULD** is a strong recommendation whose deviation must be deliberate and documented, typically because the behavior is a reference-implementation choice rather than an interop invariant. A **MAY** marks an optional capability or an explicitly permitted latitude. -Every normative statement carries a stable requirement identifier of the form `PREFIX-N` (for example `HTTP-3`, `RETRY-7`, `SEAM-30`). The prefix names the subsystem the requirement originates in: `SEAM` (extension model), `HTTP` (wire model), `IO` (streaming), `BODY` (payload lifecycle), `CTX` (execution context), `PIPE` (stage pipeline), `RECOV` (recovery chain), `RETRY` (resilience), `REDIR` (redirects), `AUTH` (authentication), and `NFR` (quality bar). These identifiers are load-bearing: cross-references between subsystems use them, and a conformance suite should tag each test with the identifier it exercises. Each requirement is presented as its identifier and level, a normative statement, a one-line rationale, and a one-line note on how to conformance-test it. Where the Kotlin/JVM SDK realizes a requirement in a way worth knowing, a short *Reference implementation:* note is added in italics; that note is informative only, and the requirement above it is the portable contract. +Every normative statement carries a stable requirement identifier of the form `PREFIX-N` (for example `HTTP-3`, `RETRY-7`, `SEAM-30`). The prefix names the subsystem the requirement originates in: `SEAM` (extension model), `HTTP` (wire model), `IO` (streaming), `BODY` (payload lifecycle), `CTX` (execution context), `PIPE` (stage pipeline), `RECOV` (recovery chain), `RETRY` (resilience), `REDIR` (redirects), `AUTH` (authentication), `PAGE` (pagination), `SSE` (server-sent events), `SERDE` (serialization), `OBS` (instrumentation and observability), `CFG` (configuration), `TRANSPORT` (transport adapter contract), `ASYNC` (async runtime adapter contract), `XCUT` (cross-cutting invariants), `NFR` (quality bar), and `PLAN` (planned capabilities defined in §21, not yet in the reference implementation). These identifiers are load-bearing: cross-references between subsystems use them, and a conformance suite should tag each test with the identifier it exercises. Each requirement is presented as its identifier and level, a normative statement, a one-line rationale, and a one-line note on how to conformance-test it. Where the Kotlin/JVM SDK realizes a requirement in a way worth knowing, a short *Reference implementation:* note is added in italics; that note is informative only, and the requirement above it is the portable contract. -**Scope.** This document covers the SDK's product thesis, its durable architectural principles, its pluggable extension seams, the immutable HTTP domain model, the byte-streaming and body-lifecycle contracts, the execution-context model, both execution-pipeline layers, and the resilience concerns layered on top of them: retry, redirect following, and authentication. A companion document (produced separately) covers pagination, server-sent events, serialization, instrumentation, configuration and utilities, the transport and async-runtime adapter contracts, cross-cutting invariants, and the non-functional quality bar; requirements from those subsystems are cited here where they cross a boundary. +**Scope.** This document covers the SDK's product thesis, its durable architectural principles, its pluggable extension seams, the immutable HTTP domain model, the byte-streaming and body-lifecycle contracts, the execution-context model, both execution-pipeline layers, and the resilience concerns layered on top of them: retry, redirect following, and authentication. It continues through pagination, server-sent events, serialization, instrumentation and observability, configuration and utilities, the transport and asynchronous-runtime adapter conformance contracts, the cross-cutting invariants, and the non-functional quality bar, and closes — in §21 — with the planned capabilities not yet provided by the reference implementation. **Non-goals of this specification.** This document does not prescribe an implementation language, a concurrency framework, a build system, or a wire library. It does not define a code-generation layer or DTO model; it specifies the runtime primitives generated code would rely on. It does not restate HTTP, TLS, or URI semantics that the referenced RFCs already define, except where the SDK deliberately narrows or tolerates them. It does not mandate the reference implementation's exact class names, package layout, or numeric tuning constants except where a constant is itself part of the contract (those are called out explicitly). Finally, it contains no "Consolidated Normative Requirement Index"; that index is generated mechanically and appended after this text. @@ -43,7 +43,7 @@ These are the durable principles a port must preserve even as individual subsyst **Security by default.** Safe behavior is the default; exposing a secret or lowering a guarantee must be an explicit opt-in. -- Credentials are transport-scoped and never stamped over plaintext (**AUTH-28**); redirects strip credentials and never launder them cross-origin (**REDIR-7**, **REDIR-9**, **REDIR-8**); header names and values are validated against request-splitting before any transport sees them (**HTTP-17**–**HTTP-19**); Digest client nonces come from a cryptographically strong source (**AUTH-20**); and log-preview and error-body buffers are bounded (**HTTP-52**, **BODY-30**). +- Credentials are transport-scoped and never stamped over plaintext (**AUTH-28**); redirects strip credentials and never launder them cross-origin (**REDIR-7**, **REDIR-9**, **REDIR-8**); header names and values are validated against request-splitting before any transport sees them (**HTTP-17**–**HTTP-19**); Digest client nonces come from a cryptographically strong source (**AUTH-20**); and log-preview and error-body buffers are bounded (**BODY-34**, **HTTP-52**, **BODY-30**). **Cancellation correctness.** Cancellation is distinct from timeout, propagates into the network layer, and never silently disappears. @@ -52,6 +52,14 @@ These are the durable principles a port must preserve even as individual subsyst **Minimal-footprint core.** The core stays small and dependency-free so a consumer's footprint is proportional to the features actually used (**SEAM-1**, **NFR-1**, **NFR-2**). Optional capabilities — each transport, codec, I/O backend, and async bridge — are separately installable units depending on the core plus at most one third-party library. +**A platform, judged by its consumers.** The SDK is a platform, and its users are external: generated service clients, downstream SDKs, and application code that build on top of the core. The public API therefore exists to be built upon — a capability's worth is judged by what a consumer needs, not by whether the core itself happens to call it. + +- **SEAM-31** (**MUST**): The public API MUST be treated as platform surface for external consumers. A public primitive with no in-core caller is expected on a platform and MUST NOT be judged dead code or removed on that basis alone; conversely, every shipped public capability MUST carry the discoverability a consumer needs to adopt it — a documented contract, and for a non-obvious composition at least one worked example or assembly. *Rationale:* on a platform the users are external, so internal call-count measures neither a primitive's value nor its safety to remove. *Conformance:* for each public capability confirm a documented contract exists (and a worked example for non-obvious compositions), and confirm any removal rationale cites consumer need rather than the mere absence of an internal caller. + +**Behavioral equivalence across interchangeable adapters.** Two implementations of the same seam are interchangeable units; a consumer picks between them for performance and dependencies, never expecting the bytes or headers it observes to change. + +- **SEAM-32** (**MUST**): For behavior the core owns, interchangeable implementations of the same seam — two transports, two codecs, two I/O backends — MUST NOT diverge in observable results for the same input, differing only in performance and dependencies. In particular, an adapter MUST NOT apply a wire transformation a sibling does not (for example, transparent response decompression) unless the core mandates that transformation uniformly for every adapter; where such a transformation is wanted it belongs in the core as a single authority (single-sourced correctness, above; **PLAN-1**), never baked unevenly into one adapter. Genuinely native, single-adapter behaviors are permitted only where §17 scopes them explicitly. *Rationale:* a consumer chooses an adapter for performance and dependencies, not to change the bytes or headers it observes. *Conformance:* run the transport conformance suite (§17) against every adapter of a seam and assert identical observable results for core-owned behavior. + ## 3. Pluggable Seams and Extension Model The SDK's extensibility rests on a fixed, small set of interface seams plus the async pivot. This section specifies each seam's contract, how a plug-in is discovered and resolved, and who owns the lifecycle of what. @@ -987,6 +995,56 @@ These language-neutral non-functional invariants are the cross-cutting engineeri - **NFR-15 (SHOULD).** Published artifacts SHOULD embed self-identifying version metadata the SDK can resolve at runtime, so runtime-emitted identifiers (e.g. a User-Agent) report the real version rather than an "unknown" placeholder. *Conformance: query the SDK's self-reported version at runtime from a packaged artifact; it must equal the build version and never the placeholder.* - **NFR-16 (SHOULD).** Published artifacts SHOULD be cryptographically signed for provenance, with signing enforced on the release/CI path and made gracefully optional in local builds lacking signing keys. *Conformance: a CI/release build fails an unsigned publication; a local build without keys still publishes unsigned.* +## 21. Planned Capabilities (Extension Roadmap) + +The capabilities in this section are specified but **not yet provided by the reference implementation**. Each entry defines the contract a conforming SDK MUST satisfy *when* the capability is built, states whether it belongs in the core or in a separately installable adapter unit (§2, §3), and links to its tracking issue. These `PLAN-*` requirements are intentionally excluded from the Appendix C index, which enumerates current behavior; a `PLAN-*` requirement graduates into its subsystem's numbering — and into Appendix C — when it ships. + +Every entry preserves the platform philosophy of §2: correctness lives once in the core (single-sourced correctness; behavioral equivalence across adapters), third-party runtimes stay in opt-in adapter units (**SEAM-1**, **NFR-2**), the default wire behavior does not change unless a consumer opts in, and each capability is shipped with the discoverability an external consumer needs to adopt it (**SEAM-31**). + +### 21.1 Response decompression and content-coding negotiation + +Tracking: dexpace/java-sdk#212. The core performs no content-coding handling today, and the two reference transports diverge for an identical request — an SDK-managed OkHttp client transparently inflates `gzip` while the `java.net.http` transport returns the raw compressed bytes — which violates behavioral equivalence (**SEAM-32**). + +- **PLAN-1** (**MUST**): Content-coding decoding MUST be a single core authority, not a per-adapter behavior. A conforming SDK MUST decode at least `gzip` and `deflate` using only its standard library (no new runtime dependency; **SEAM-1**), streaming so the lazily-read, non-pre-buffered body contract (**TRANSPORT-25**, **SEAM-11**) is preserved, and MUST decode a multi-valued `Content-Encoding` in reverse order. `identity` and any unsupported coding MUST pass through untouched rather than corrupt the body. +- **PLAN-2** (**MUST**): Negotiation and decoding MUST be opt-in and MUST NOT change default wire behavior. When enabled, a single pipeline step sets outbound `Accept-Encoding` and decodes the inbound response; server-sent-event, other streaming/unknown-length, and body-less responses are skipped. After decoding, the body MUST be presented with `Content-Encoding` removed and its length reported unknown. +- **PLAN-3** (**MUST**): An SDK-managed transport MUST NOT apply a native content-coding transformation the core does not apply uniformly, and a `TRANSPORT-*` conformance requirement MUST codify decode parity across adapters; a caller-supplied (BYO) native client's behavior is left untouched. Codings that require a third-party runtime (`br`, `zstd`) MUST live in an opt-in adapter unit, not the core. + +### 21.2 Reference observability adapter + +Tracking: dexpace/java-sdk#213. The core defines the observability seams (meter/instruments, tracer) and already emits OpenTelemetry-named instruments (**OBS-32**) and spans, but ships only no-op implementations, so a consumer that does not hand-write a bridge gets nothing. + +- **PLAN-4** (**SHOULD**): The SDK SHOULD ship at least one reference observability adapter (e.g. OpenTelemetry) as a separately installable unit that maps the core's tracer events and metric instruments onto a real backend. The backend runtime MUST be pulled only into the adapter; the core stays seam-only (**SEAM-1**, **NFR-2**). Instrument names, units, and attributes remain defined by the core (**OBS-32**), so observability adapters differ only in backend wiring — preserving behavioral equivalence across backends. + +### 21.3 OAuth2 token-provider adapter + +Tracking: dexpace/java-sdk#214. The bearer-token plumbing exists — token value plus expiry, the token-provider seam, single-flight refresh with expiry-margin caching and header-matched 401 eviction — but no shipped provider actually acquires tokens; the token-provider seam is explicitly documented as where OAuth token exchange belongs. + +- **PLAN-5** (**SHOULD**): The SDK SHOULD ship an OAuth2 token-provider adapter for at least the `client_credentials` and `refresh_token` grants, as a separately installable unit reusing the transport, codec, and clock seams. It MUST construct the form-encoded token-endpoint request, support the standard client-authentication methods (secret in the `Authorization` header versus in the form body), and parse the token and expiry into the core's bearer-token type. Token acquisition MUST stay out of the core (**AUTH**; **SEAM-1**). + +### 21.4 Challenge-driving authentication step + +Tracking: dexpace/java-sdk#215. The auth package ships complete, spec-mandated Basic/Digest/Composite challenge handlers (**AUTH-14**–**AUTH-25**) and a challenge parser (**AUTH-12**–**AUTH-13**), but no shipped pipeline step drives a `401` `WWW-Authenticate` through a handler into a rebuilt request — so Digest is unreachable end-to-end for a consumer, and the handlers have no assembly primitive. + +- **PLAN-6** (**MUST**): A conforming SDK MUST provide an authentication-pillar step (synchronous and asynchronous) that, on a `401` carrying `WWW-Authenticate`, parses the challenge, delegates to a supplied challenge handler (single or composite), and returns the rebuilt request through the existing replayability-gated challenge path (**AUTH-31**), scoped to `401`/`WWW-Authenticate`. Because these handlers are shipped public surface, the SDK MUST also provide a worked example wiring Basic and Digest (**SEAM-31** discoverability). Digest client nonces remain sourced from a cryptographically strong generator (**AUTH-20**). + +### 21.5 Client-side rate limiting + +Tracking: dexpace/java-sdk#216. The SDK reacts to server pacing (`Retry-After`, `X-RateLimit-Reset`) but offers no proactive client-side throttle. + +- **PLAN-7** (**MAY**): The SDK MAY provide an optional, opt-in client-side rate-limiter pipeline step (a time-based token bucket) in the core using only standard-library primitives (no new dependency), installed by the consumer rather than by default, with a non-blocking asynchronous mirror. Its blocking wait MUST honor cooperative cancellation (the interrupt convention of §19) and MUST NOT pin a lightweight scheduler carrier (**XCUT-3**). A circuit breaker is a separate, larger concern and is out of scope for this entry. + +### 21.6 Transfer-progress observation + +Tracking: dexpace/java-sdk#217. The toolkit invests in large-payload transfer (zero-copy file bodies, multipart, ranges) but exposes no primitive to observe transfer progress. + +- **PLAN-8** (**MAY**): The SDK MAY provide a zero-dependency counting source/sink decorator (or a progress-listener a body can be wrapped with) in the I/O layer for incremental byte-transfer progress. The contract MUST document its ceiling: a generic decorator observes only bytes that flow through the buffered stream, so a zero-copy file transfer a transport dispatches through a kernel copy path (§6) bypasses it and reports no progress without explicit transport cooperation. + +### 21.7 Opt-in asynchronous redirect following + +Tracking: dexpace/java-sdk#218. The synchronous standard pipeline follows redirects with full credential hygiene; the asynchronous pipeline follows none by design (**PIPE-32**, **REDIR-25**), so a consumer moving to the asynchronous API loses SDK-managed redirect following and its cross-origin credential stripping. + +- **PLAN-9** (**SHOULD**): The SDK SHOULD provide an opt-in asynchronous redirect step mirroring the synchronous redirect contract — the same 3xx handling, per-hop response closing, and cross-origin credential and internal-marker stripping (**REDIR-7**–**REDIR-11**, with the auth-side marker strip at **AUTH-29**) — expressed without blocking a thread. It MUST remain outside the asynchronous standard-resilience preset so the redirect-free asynchronous default (**PIPE-32**) is unchanged; enabling it is the consumer's explicit choice. + --- ## Appendix A — Glossary @@ -1157,7 +1215,7 @@ A porter runs this checklist to prove a reimplementation conforms. Each item ref ## Appendix C — Consolidated Normative Requirement Index -Every normative requirement defined in this specification, aggregated for conformance tracking. This index lists 645 requirements across 19 subsystems. +Every normative requirement defined in this specification, aggregated for conformance tracking. This index lists 647 requirements across 19 subsystems. Planned capabilities (§21, `PLAN-*`) are not indexed here; each graduates into this index when it ships. | ID | Level | Subsystem | Requirement | |---|---|---|---| @@ -1191,6 +1249,8 @@ Every normative requirement defined in this specification, aggregated for confor | SEAM-28 | MAY | Product vision, pluggable seams and extension model | The operation projection MAY carry a stable operation identifier for instrumentation/tracing; when present it is attached to the request's context chain but MUST NOT affect the assembled request's URL, headers, or body. | | SEAM-29 | MUST | Product vision, pluggable seams and extension model | Public model construction MUST go through the immutable-value + Builder pattern: models are immutable, constructed only via a builder whose build() materializes a new instance, and a shared generic Builder contract (build() producing the target type) MUST exist so generic composition/folding helpers can accept any builder. Required-field validation MUST be uniform: a missing required field fails at build() with a consistent, field-named error message of the form ' is required'. | | SEAM-30 | MUST | Product vision, pluggable seams and extension model | On any path where a send produces a response value that the returned future will NOT hand to a caller — because the future was already cancelled or completed exceptionally (the completion race) — the producer MUST close that orphaned response so its underlying connection/descriptor is not leaked. This closes the gap in the caller-closes-the-response rule (SEAM-16), which cannot apply to a value no caller receives, and applies to native async transports, the sync->async bridge, and every ecosystem adapter that can drop a produced value. | +| SEAM-31 | MUST | Product vision, pluggable seams and extension model | The public API is platform surface for external consumers; a public primitive with no in-core caller is not dead code and is not removed on that basis alone, while every shipped public capability provides consumer-facing discoverability (a documented contract, and a worked example for non-obvious compositions). | +| SEAM-32 | MUST | Product vision, pluggable seams and extension model | For core-owned behavior, interchangeable implementations of the same seam MUST NOT diverge in observable results for the same input, differing only in performance and dependencies; an adapter MUST NOT apply a wire transformation a sibling does not (e.g. transparent response decompression) unless the core mandates it uniformly (where it belongs in the core as a single authority), and genuinely native single-adapter behaviors are permitted only where the transport conformance contract scopes them explicitly. | | HTTP-1 | MUST | Core HTTP domain model | All core domain-model types (Request, Response, Headers, MediaType, QueryParams, RequestOptions, RequestConditions, Status, ETag, HttpRange, the typed header name, Protocol, Method) MUST have an immutable value/metadata surface after construction, safe to share across threads without external synchronization; any change MUST produce a new instance rather than mutating an existing one. The one carve-out: a body attached to a Request/Response may carry single-use stream state whose consumption is NOT thread-safe (see the body requirements), and per-call tag values are opaque so their own mutability is the caller's concern. | | HTTP-2 | MUST | Core HTTP domain model | Model instances MUST be constructible only through their builder or dedicated factory, never by exposing a public field-wise constructor or an unchecked copy operation that bypasses builder/factory validation. | | HTTP-3 | MUST | Core HTTP domain model | Each builder-based model (Request, Response, Headers, QueryParams, RequestOptions, RequestConditions, and the multipart body) MUST expose a newBuilder()-style derivation returning a builder pre-populated with the instance's current fields, so a caller can derive a modified copy without restating unchanged fields; the pre-filled builder MUST NOT alias the original's internal collections (each value list is copied). Value-based types that have no builder (MediaType, Status, the typed header name, ETag, HttpRange, Method, Protocol) are instead derived by re-constructing through their factories and intentionally expose no builder. |