As built by phase 1, in dexpace-core, written against source on 2026-09-15. This page says
how the wire model composes and what to expect from it. What each type is required to do is
docs/product-spec/04-core-http-domain-model.md; how the design maps it to Ruby is
docs/sdk-design-ruby/04-domain-model-construction.md; the per-requirement proof is
docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md. Signatures live in
gems/dexpace-core/sig/, and this page does not restate them.
Every value type here is class X < Data.define(...) including Dexpace::Model, with new
private and a public, validating .build. That buys four properties at once, and they are the
same for Status as for Request:
- Frozen on construction. A model never changes; a collection member is copied and
deep-frozen once, at construction and only after its shape has been validated (
Model.own, called from eachinitialize), so an accessor hands back the model's own frozen object with no per-call copy, aHashorArraythe caller keeps a reference to cannot reach the model afterwards, and an object that cannot be copied — aProcwhere aHashbelongs, aThreadinside a value list — is refused asInvalidArgumentErrorrather than escaping as the stdlib'sTypeError; a caller-suppliedStringmember (Response#reason, aMethodtoken, aHeaderName) is copied and frozen the same way (Model.frozen_string). Every model —RequestandResponseincluded, when the opaque body they carry isnilor frozen — isRactor.shareable?, which is the one-line proof the freeze reached every level and is not otherwise relied on. - Validated wherever it was built. Validation lives in each type's
initialize, so.build,#withand a builder's#buildall meet it, and a missing required member fails with the one message form<name> is required(Dexpace::Model.required!). - Derived, never mutated. A type with no builder derives through
#with(member: value), which routes through.build—Dexpace::Model#withoverridesData#withbecause the inherited one skips aninitializeoverride on Ruby 3.2. A type with a builder derives through#new_builder, a builder pre-filled from the instance that copies every collection. - Coerced at the boundary. A factory that has a typed form accepts the raw form too and
is idempotent on its own type:
Request.build(method: "post", url: "https://…")holds aDexpace::Methodand a frozenURI::Generic;Response.build(protocol: "HTTP/2.0", status: 404, …)holds aProtocoland aStatus.
One error class covers every caller mistake: Dexpace::InvalidArgumentError, a subclass of
Ruby's ArgumentError that includes the SDK's root, Dexpace::Error. The root is a module
so that a later transport error can subclass IOError and still be caught by
rescue Dexpace::Error. Dexpace::ArgumentError is never defined.
require "dexpace"
builder = Dexpace::Request.builder
builder.url = "https://api.example.test/widgets?page=2"
builder.method = :post # a String, a Symbol or a Dexpace::Method
builder.body = "{}" # opaque until phase 3's body model; carried, not frozen
request = builder.header("Accept", "application/json").build
request.method # => Dexpace::Method::POST
request.method.idempotent? # => false
request.headers["ACCEPT"] # => ["application/json"] (frozen; the model's own list)
request.headers.names # => ["Accept"] (a fresh frozen snapshot)
Dexpace::URL.external_form(request.url) # => "https://api.example.test/widgets?page=2"
retried = request.new_builder.header("X-Attempt", "2").build
request.headers.include?("X-Attempt") # => false: the derivation copied, never aliased
response = Dexpace::Response.builder
response.request = request
response.protocol = "HTTP/1.1"
response.status = 429
response.reason = +"Too Many Requests" # copied and frozen at build (XCUT-15)
built = response.build
built.status # => Dexpace::Status::TOO_MANY_REQUESTS
built.error? # => true, derived from the status
built.headers.direction # => :inbound, so obs-text is admitted (HTTP-19)The builders carry exactly the rules that are about a field nobody set: a request with neither
method nor body is a GET; a body with no method reports method is required rather than
defaulting to GET and then rejecting the body; a GET, HEAD, TRACE or CONNECT with a body
is refused at build, and so is get_request.with(body: "…"), because the model checks too.
A request's headers are always outbound-validated. Request.build, Request#with and
Request::Builder#headers= refuse a Headers built by Headers.inbound_builder — whatever it
holds, because the direction is what every later #header and #new_builder would inherit — so
a response's headers cannot be copied onto a request without going back through the outbound
grammar (HTTP-18, XCUT-18).
Dexpace::Headers folds names for lookup, containment, equality and hashing and keeps the first
casing it saw for emission; two collections differing only in casing, or in the direction that
validated them, are equal. Every name and every value is validated as bytes by
Dexpace::HeaderSyntax, the module every transport re-runs immediately before dispatch:
| Input | Outbound (a request) | Inbound (a response) |
|---|---|---|
| a name with CR, LF, NUL, DEL, a space or any byte ≥ 0x80 | rejected | rejected |
" X-Trace " |
accepted as X-Trace — SP and HTAB are trimmed, nothing else |
same |
| a value with HTAB | accepted | accepted |
| a value with a byte ≥ 0x80 (obs-text) | rejected | accepted |
| a value with CR, LF or DEL | rejected | rejected |
a String whose bytes are not valid UTF-8 |
rejected with InvalidArgumentError |
as the rows above |
a name whose bytes are ASCII under a tag that cannot carry ASCII ("Accept".encode("ISO-2022-JP"), UTF-16) |
accepted; stored and folded under US-ASCII | same |
A rejected value never appears in the error message; a rejected name is echoed with its control
bytes escaped. The last row is HeaderSyntax.ascii_compatible, the one normalisation core applies before
it folds, upcases or scans anything a byte check has passed — Method.of, Protocol.parse and
MediaType.parse go through it too — so a legal tag Ruby refuses to fold under never reaches a caller
as Encoding::CompatibilityError. Headers.builder validates by the outbound grammar and Headers.inbound_builder
by the inbound one; the direction is a member of the built collection, so a builder derived from
a response's headers stays lenient.
Dexpace::HeaderName is the typed name: equal and hashed by its fold, emitted with its original
casing, accepted everywhere a String name is.
Status— total over every Integer (HTTP-10):Status.of(520)andStatus.of(999)are statuses with no canonical name, never an error, and only a non-Integer is refused;Status.canonical_name(code)is the separate lookup. Equality is by code alone. Range predicates (success?,error?, …) are derived,#standard?says whether the code is inside the protocol's100–599classes, andResponsedelegates the same six class predicates.Method— an upper-cased RFC 7230 token, so extension methods are representable.Method::IDEMPOTENTand#idempotent?are the single source the retry layer derives from;#body_forbidden?is the classification the request builder asks. Insidemodule Dexpace,Methodis this class; write::Methodfor Ruby's.Protocol—http/1.0,http/1.1orhttp/2(HTTP_1_0,HTTP_1_1,HTTP_2);Protocol.parseaccepts those and the aliasHTTP/2.0, case-insensitively, and raises on anything else —HTTP/1.2,http/0.9,spdy/3.MediaType— type, subtype and parameter keys folded, parameter values as given;parsesplits parameters respecting quoted-strings andrenderre-quotes, soparse(render(x)) == x;#charsetisnilfor an absent or unknown charset, never a raise;#matches?handles*/*andtype/*.Query— an insertion-ordered list of[name, value]pairs, case-sensitive, multiple values per name,?flagas a single empty-string value.#encodeuses the strict RFC 3986 component encoder (Dexpace::PercentEncoding: space is%20,+is%2B,~is bare) andQuery.parseis its lenient inverse. Two queries are equal exactly when they encode identically.RequestOptions— per-calltimeout(aFloatof seconds),max_retriesandtags, aString-to-Stringmap (values too — a deliberate narrowing ofHTTP-34's "opaque", ledger rowP1-14), every fieldnil/empty by default andRequestOptions::EMPTYthe shared "override nothing". These are operational knobs and deliberately not part ofRequest.URL— a module, not a type:URL.parse!pinsURI::RFC3986_PARSER, refuses a relative URI, and returns aURI::Genericfrozen through its components;URL.external_formis the textual keyRequestcompares by, with no name resolution.
The body model, Response#close and the body lifecycle are phase 3's; the pipeline, seams and
transports that carry these values are later phases'. ETag, the HTTP range helper and the
conditional-request aggregator (HTTP-48–HTTP-50) and header-name interning (HTTP-22) are not
built in v1 — see docs/first-release.md.