From 00f4028a5cb0e00ef8b049ee3c9da869dc6147e0 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 00:11:45 +0300 Subject: [PATCH 1/4] docs: phase 1 checklist, ledger rows, status note and the as-built domain-model page The forty-two-row checklist, written from what was built; three ledger rows added at implementation (P1-11, P1-12, P1-13) and P1-9 retired as built, with the corpus note that carried it corrected; the roadmap's dated status note and a thirty-fourth phase-10 inbound bullet; CLAUDE.md, the READMEs and docs/first-release.md brought to the built tree; and docs/sdk-documentation/http.md, the first as-built page for dexpace-core. --- CLAUDE.md | 43 ++-- README.md | 7 +- docs/README.md | 12 +- docs/first-release.md | 3 +- docs/knowledge/notes/data-modeling.md | 2 + docs/sdk-documentation/architecture.md | 14 +- docs/sdk-documentation/http.md | 130 +++++++++++ docs/sdk-documentation/quality-gates.md | 6 +- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 41 ++++ ...phase1-core-http-domain-model-checklist.md | 219 ++++++++++++++++++ ...05-phase1-core-http-domain-model-design.md | 5 +- gems/dexpace-core/README.md | 22 +- 12 files changed, 467 insertions(+), 37 deletions(-) create mode 100644 docs/sdk-documentation/http.md create mode 100644 docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md diff --git a/CLAUDE.md b/CLAUDE.md index 94b393b..e4a85d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,14 +13,18 @@ three-state PATCH — solved exactly once, and it deliberately does not compete Work here is **spec-driven, not feature-driven**. `docs/product-spec/` is normative: 645 numbered requirements across 19 prefixes. Before implementing anything, find the requirement IDs it must satisfy. -**Phase 0 is built; no domain code is.** Six gem skeletons exist under `gems/`, every one at `0.0.0`, and each -gem's `lib/` holds its namespace module and a `VERSION` constant and nothing else. The workspace root carries the -`Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and the -seventeen blocking gates (`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`). +**Phases 0 and 1 are built; the domain model is the only domain code.** Six gems exist under `gems/`, every one +at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`, `Response`, `Headers`, `Status`, +`Method`, `Protocol`, `MediaType`, `Query`, `RequestOptions`, `HeaderName`, the `HeaderSyntax`, `PercentEncoding` +and `URL` function modules, and the construction contract `Dexpace::Model` / `Dexpace::Builder` under one error +root, `Dexpace::Error` (`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md`); every +other gem's `lib/` still holds its namespace module and a `VERSION` constant and nothing else. The workspace root +carries the `Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and +the seventeen blocking gates (`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`). Beyond that, what exists is the specification, the port design, the process tooling and the register, `docs/deviations.md`. Ruby **>= 3.2** is the floor (`required_ruby_version` in every gemspec, asserted by `gates:versions`); CI runs a 3.2 / 3.3 / 3.4 / 4.0 matrix; every Ruby fact in the design was verified against -3.4.10 and the ones the gates rest on were re-verified against 3.2.11, 3.4.10 and 4.0.6. +3.4.10 and the ones the gates and the domain model rest on were re-verified against 3.2.11, 3.4.10 and 4.0.6. Top-level namespace is `Dexpace`. Gem names are hyphenated and map segment-for-segment onto the constant path: `dexpace-transport-net_http` → `lib/dexpace/transport/net_http.rb` → `Dexpace::Transport::NetHTTP`. @@ -302,6 +306,15 @@ invariants no tool catches. - **`downcase` is called with no arguments, everywhere in core.** Ruby's fold is opt-in-locale (`"I".downcase(:turkic)` → `"ı"`), and the same lint rule that forbids `Time.parse` forbids a locale symbol on `downcase`/`upcase`/`casecmp` repository-wide, so `HTTP-13` is enforced rather than assumed. +- **`#with` routes through the type's validating `.build`, never through `Data#with`.** `Data#with` does not call + an `initialize` override on Ruby 3.2 (it does on 3.4 and 4.0), so the inherited derivation skips every + `HTTP-4`/`SEAM-29` check on the declared floor; `Dexpace::Model#with` is the one override, every model gets it by + `include Model`, and `test:gems` on 3.2.11 is the run that proves it (phase 1's design, addendum A1). +- **`Dexpace::Error` is a module, included by every core error class, not a base class**, so `XCUT-4`'s + `Dexpace::TransportError < ::IOError` stays reachable under single inheritance; `rescue Dexpace::Error` matches + through `Module#===`. Phase 1's only error is `Dexpace::InvalidArgumentError < ::ArgumentError`, and + `Dexpace::ArgumentError` is never defined, because it would shadow Ruby's inside `module Dexpace` (phase 1's + design, addendum A3). ## Constraints that will bite @@ -431,16 +444,18 @@ probe compares each against the live tree, and a count written anywhere else in - Six gems exist under `gems/`, all at `0.0.0` and none published: `dexpace-core`, `dexpace-transport-net_http`, `dexpace-transport-async_http`, `dexpace-serde-json`, `dexpace-async-thread` - and `dexpace-conformance`. Each is a phase-0 skeleton — a gemspec reading `VERSIONS`, a `lib/` holding the - namespace module and a `VERSION` constant and nothing else, a `sig/` mirroring it one file per file, a - smoke suite, a README, a LICENSE copy and a per-gem `Rakefile`. Every adapter gemspec declares - `dexpace-core` and no third-party gem yet (design P0-9); the third-party half of each `NFR-2` budget arrives - with the phase that writes the code needing it. + and `dexpace-conformance`. Each has a gemspec reading `VERSIONS`, a `sig/` mirroring its `lib/` one file + per file, a smoke suite, a README, a LICENSE copy and a per-gem `Rakefile`. `dexpace-core`'s `lib/` holds + the phase-1 HTTP domain model — twenty-two files under `lib/dexpace/`, every one mirrored in `sig/` and + `test/`; every other gem is a phase-0 skeleton whose `lib/` holds the namespace module and a `VERSION` + constant and nothing else. Every adapter gemspec declares `dexpace-core` and no third-party gem yet + (design P0-9); the third-party half of each `NFR-2` budget arrives with the phase that writes the code + needing it. - There are eleven phase directories under `docs/work/*/`; `mvp/` is the only delivery, and it holds the v1 roadmap, `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md`, plus `phase0/`, - `phase1/`, `phase2/`, `phase3/`, `phase4/`, `phase5/`, `phase6/`, `phase7/`, `phase8/`, `phase9/` and `phase10/`. `phase0/` carries its design, - plan and checklist — the one checklist written so far, at implementation; `phase1/` and `phase2/` carry - that phase's design and plan; `phase3/` carries its segmentation design, + `phase1/`, `phase2/`, `phase3/`, `phase4/`, `phase5/`, `phase6/`, `phase7/`, `phase8/`, `phase9/` and `phase10/`. `phase0/` and `phase1/` each carry + that phase's design, plan and checklist — the two checklists written so far, each at implementation; + `phase2/` carries its design and plan; `phase3/` carries its segmentation design, `docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`, and two sub-phase directories — `phase3/phase3a/` and `phase3/phase3b/`, each holding that sub-phase's design and plan; `phase4/` carries its segmentation design, @@ -511,5 +526,5 @@ probe compares each against the live tree, and a count written anywhere else in `gates:sole_parse`) and a ninth probe check for chapter attribution — none of the four built yet — and closes or narrows five `docs/first-release.md` lines while publishing nothing: every gem stays at `0.0.0`. - Every checklist but phase 0's is still to be written at execution time. + Every checklist but phase 0's and phase 1's is still to be written at execution time. - There are 40 harvested topics under `docs/knowledge/harvested/`; the harvest ran here on 2026-09-05. diff --git a/README.md b/README.md index 505a175..1910bb6 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ not compete with `faraday` or `httpx` on the easiest way to fetch a JSON endpoin ## Status -**Phase 0 is built; no domain code is.** Nothing is published. The repository holds six gems under -`gems/`, every one at `0.0.0` and every one a skeleton — a namespace, a `VERSION`, a gemspec, a -signature mirror and a smoke suite: +**Phases 0 and 1 are built.** Nothing is published. The repository holds six gems under `gems/`, +every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — the frozen, validated wire +types every later phase stands on (`docs/sdk-documentation/http.md`); the other five are still +skeletons — a namespace, a `VERSION`, a gemspec, a signature mirror and a smoke suite: | Gem | Namespace | Runtime dependencies today | |---|---|---| diff --git a/docs/README.md b/docs/README.md index 075a2bd..97bf37d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,12 +99,14 @@ a different namespace and the probe does not confuse them. ## What the tree beside `docs/` holds -Six gems exist under `gems/`, all at `0.0.0` and none published — phase 0's skeletons, each a -namespace, a `VERSION`, a gemspec reading the root `VERSIONS` file, a `sig/` mirror and a smoke -suite. The as-built documentation for them lands in [`sdk-documentation/`](./sdk-documentation/) -as each gem gains code; the one page written so far is +Six gems exist under `gems/`, all at `0.0.0` and none published — each a gemspec reading the +root `VERSIONS` file, a `sig/` mirror and a smoke suite. `dexpace-core` holds phase 1's HTTP +domain model; the other five are phase 0's skeletons, a namespace and a `VERSION`. The as-built +documentation lands in [`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the +two pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the gate -set is the thing phase 0 actually built. +set is the thing phase 0 built, and [`sdk-documentation/http.md`](./sdk-documentation/http.md), +because the domain model is the thing phase 1 built. ## Keeping this file true diff --git a/docs/first-release.md b/docs/first-release.md index b3c9413..c3f73fc 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -9,7 +9,8 @@ constant shadow, and the `AuthDescriptor` carrier decision), `CFG-20`'s cancel-w under the unsatisfied MUSTs, and the `CTX-7`/`CTX-8` drain proof under the post-release triggers. Two more left a second home here beside their primary owner: the Minitest 6 trigger, and the red `gates:bounded_map` blocker phase 10's repair clears. **Nothing has been published.** Since phase 0 landed -(2026-09-14) `gems/` holds the six skeletons; there is still no tag, and no version beyond the `0.0.0` every +(2026-09-14) `gems/` holds the six gems, and since phase 1 (2026-09-15) `dexpace-core` carries the HTTP domain +model; there is still no tag, and no version beyond the `0.0.0` every gem starts at. ## Gems, once they exist diff --git a/docs/knowledge/notes/data-modeling.md b/docs/knowledge/notes/data-modeling.md index 2018f29..9e5417f 100644 --- a/docs/knowledge/notes/data-modeling.md +++ b/docs/knowledge/notes/data-modeling.md @@ -15,3 +15,5 @@ entry's stable key. review · `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md` · high · sha:manual-phase1-data-with - **The wire model is Ractor-shareable everywhere except where a member is a `URI::Generic`, and buying shareability there would deep-freeze a process-global parser.** Supersedes `data-modeling/996c0b12`, whose "deep-freezing at construction makes the whole wire model Ractor-shareable today" was verified on 3.4.10 against a `Data` holding a frozen `Hash` — which is true, and does not generalise to the two models that hold a parsed URL. Verified on 2026-09-05 against 3.2.11 and 4.0.6: `URI::RFC3986_PARSER.parse("https://example.test/a?x=1").freeze` is `frozen?` but **not** `Ractor.shareable?`, because `URI::Generic#freeze` is shallow and its `@host` and `@path` strings stay unfrozen; `Ractor.make_shareable(uri)` does return a shareable URI, and in the same call leaves `URI::RFC3986_PARSER.frozen?` true — it walks the object graph into the parser the URI references and deep-freezes that process-global object in place. Why it matters rather than being a curiosity: `Dexpace::Request` and `Dexpace::Response` hold a `URI::Generic`, so a phase that took the harvested sentence literally would either assert a shareability its models do not have, or reach for `make_shareable` and freeze a global as a side effect of building a request — which is exactly the in-place hazard `data-modeling/fa3eaf6e` and `/59efaae6` warn about, one level further out than they state it. What the SDK does instead: `Model.own` keeps making every collection member a deep-frozen copy, so `Headers`, `Query`, `MediaType`, `RequestOptions`, `Status`, `Method`, `Protocol` and `HeaderName` are all `Ractor.shareable?`; `Request` and `Response` are frozen with every collection member deep-frozen and are documented as not shareable, with the reason and the price of the alternative stated; and the tests assert the boundary in both directions rather than asserting the claim everywhere. `Ractor` is load-bearing nowhere in this port (`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`'s runtime floor keeps it out of the supported surface), so narrowing the claim costs nothing that was being relied on. review · `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md` · high · sha:manual-phase1-ractor-uri +- **`Request` and `Response` are `Ractor.shareable?` as built, so the whole wire model is — correcting the phase-1 entry above (sha:manual-phase1-ractor-uri), whose mechanism was half right.** The entry rests on `data-modeling/996c0b12`, which it superseded on the strength of two facts: a frozen `URI::Generic` is not shareable because `URI#freeze` is shallow and `@host`/`@path` stay unfrozen, and `Ractor.make_shareable(uri)` would deep-freeze `URI::RFC3986_PARSER` in place. Verified on 2026-09-15 against 3.2.11 and 4.0.6 while building phase 1's `Dexpace::URL`: the first fact is true and is what `XCUT-15` needed answering anyway — a frozen URI's `host` is a mutable String a reader hands out, and `URI#dup` shares those Strings with the source, so `request.url.host << "x"` could change a model in place — but the second is moot, because `URI::RFC3986_PARSER.frozen?` and `Ractor.shareable?(URI::RFC3986_PARSER)` are already **true** on both interpreters: the uri gem freezes it at definition. What the SDK does: `Dexpace::URL.parse!` re-parses a URI input from its text (never `dup`, which aliases) and freezes each String-valued instance variable of the URI it now owns, skipping the parser reference; the URI and every model holding one are then `Ractor.shareable?` with no global touched — given a `nil` or frozen body, since the body member is opaque in phase 1 and carried as given — `request_test.rb` and `response_test.rb` assert it, and design §4's original claim stands in full. The phase-1 design's ledger row `P1-9` is retired as built and its replacement is `P1-13` (`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md`). `Ractor` is still load-bearing nowhere; the assertion exists so a later change to `URL.parse!` cannot lose the property silently. + review · `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md` · high · sha:manual-phase1-ractor-uri-as-built diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md index 28c20ac..d325ef0 100644 --- a/docs/sdk-documentation/architecture.md +++ b/docs/sdk-documentation/architecture.md @@ -1,10 +1,11 @@ # Architecture **As-built docs land here gem by gem, as they land in `gems/`.** Phase 0 built the six gem -skeletons and the gate set and no domain code, so this page is still a stub: it names the pages -this tree will eventually hold and where each one's content will come from, so the plan for the -documentation exists before the documentation does. One page is real already, because its subject -is: [`quality-gates.md`](./quality-gates.md). +skeletons and the gate set, and phase 1 the HTTP domain model in `dexpace-core`; the pipeline, +seams and adapters are still to come, so this page is still a stub: it names the pages this tree +will eventually hold and where each one's content will come from, so the plan for the +documentation exists before the documentation does. Two pages are real already, because their +subjects are: [`quality-gates.md`](./quality-gates.md) and [`http.md`](./http.md). Once `dexpace-core` and the first adapters ship, this page becomes the same kind of front door the sibling Node SDK's `docs/sdk-documentation/architecture.md` is — package by package, seam by seam — and the entries below turn from plain text into real links, one at a time, as each page is @@ -12,8 +13,9 @@ actually written against source. ## Planned pages -http.md — the domain model: `Request`, `Response`, `Headers`, `Status`, `QueryParams`, and their -kin, and how each is frozen at construction and reachable only through a builder. Derives from +[http.md](./http.md) — the domain model: `Request`, `Response`, `Headers`, `Status`, `Query`, and +their kin, and how each is frozen at construction and reachable only through a builder or a +validating factory. Written against the model phase 1 shipped; derives from `docs/sdk-design-ruby/04-domain-model-construction.md`. bodies.md — request bodies as duck-typed producers, response bodies as owned resources the caller diff --git a/docs/sdk-documentation/http.md b/docs/sdk-documentation/http.md new file mode 100644 index 0000000..5b7ed70 --- /dev/null +++ b/docs/sdk-documentation/http.md @@ -0,0 +1,130 @@ +# The HTTP domain model + +**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. + +## One shape, every type + +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 build (`Model.own`), so an accessor hands back the model's own frozen + object with no per-call copy, and a `Hash` or `Array` the caller keeps a reference to cannot + reach the model afterwards. Every model — `Request` and `Response` included, when the opaque + body they carry is `nil` or frozen — is `Ractor.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`, `#with` and a builder's `#build` all meet it, and a missing required member fails with + the one message form ` is required` (`Dexpace::Model.required!`). +- **Derived, never mutated.** A type with no builder derives through `#with(member: value)`, which + routes through `.build` — `Dexpace::Model#with` overrides `Data#with` because the inherited one + skips an `initialize` override 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 a + `Dexpace::Method` and a frozen `URI::Generic`; `Response.build(protocol: "HTTP/2.0", + status: 404, …)` holds a `Protocol` and a `Status`. + +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. + +## Building a request and reading a response + +```ruby +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 +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. + +## Headers, names and bytes + +`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 rejected value never appears in the error message; a rejected name is echoed with its control +bytes escaped. `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. + +## The value types + +- **`Status`** — total over 100–599: `Status.of(520)` is a status with no canonical name, never an + error; `Status.canonical_name(code)` is the separate lookup. Equality is by code alone. Range + predicates (`success?`, `error?`, …) are derived, and `Response` delegates the same six. +- **`Method`** — an upper-cased RFC 7230 token, so extension methods are representable. + `Method::IDEMPOTENT` and `#idempotent?` are the single source the retry layer derives from; + `#body_forbidden?` is the classification the request builder asks. Inside `module Dexpace`, + `Method` is this class; write `::Method` for Ruby's. +- **`Protocol`** — `http/1.1` or `http/2`; `Protocol.parse` accepts those and the aliases + `HTTP/2` and `HTTP/2.0`, case-insensitively, and raises on anything else. +- **`MediaType`** — type, subtype and parameter keys folded, parameter values as given; + `parse` splits parameters respecting quoted-strings and `render` re-quotes, so + `parse(render(x)) == x`; `#charset` is `nil` for an absent or unknown charset, never a raise; + `#matches?` handles `*/*` and `type/*`. +- **`Query`** — an insertion-ordered list of `[name, value]` pairs, case-sensitive, multiple + values per name, `?flag` as a single empty-string value. `#encode` uses the strict RFC 3986 + component encoder (`Dexpace::PercentEncoding`: space is `%20`, `+` is `%2B`, `~` is bare) and + `Query.parse` is its lenient inverse. Two queries are equal exactly when they encode + identically. +- **`RequestOptions`** — per-call `timeout` (a `Float` of seconds), `max_retries` and string + `tags`, every field `nil`/empty by default and `RequestOptions::EMPTY` the shared "override + nothing". These are operational knobs and deliberately not part of `Request`. +- **`URL`** — a module, not a type: `URL.parse!` pins `URI::RFC3986_PARSER`, refuses a relative + URI, and returns a `URI::Generic` frozen through its components; `URL.external_form` is the + textual key `Request` compares by, with no name resolution. + +## What is not here yet + +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`. diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md index f71d841..e403599 100644 --- a/docs/sdk-documentation/quality-gates.md +++ b/docs/sdk-documentation/quality-gates.md @@ -13,7 +13,7 @@ everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T |---|---|---|---| | 1 | `rubocop` | `NFR-7`, `NFR-13`: style and lint findings are fatal; the six custom cops mechanise `CLAUDE.md`'s ban list | any finding at `--fail-level=convention` | | 2 | `cops:test` | the custom cops themselves — a cop that silently stops matching stays silent otherwise; the four ban cops see a safe-navigation call (`@thread&.kill`, `name&.downcase(:turkic)`) and the interrupt cop a block-pass (`threads.each(&:kill)`, `(&:exit)`) as well as the plain spelling | `.rubocop/test/cops_test.rb`: 45 rejected and 20 accepted sources | -| 3 | `rbs:validate` | `NFR-3`: every `sig/**/*.rbs` parses and resolves, per gem, with `--no-collection` | an `.rbs` naming an undeclared type | +| 3 | `rbs:validate` | `NFR-3`: every `sig/**/*.rbs` parses and resolves, per gem, with `--no-collection` and the signature sets of the stdlib features the require allowlist admits loaded with `-r` (`set` excepted: rbs 4 ships it under `core/`) | an `.rbs` naming an undeclared type | | 4 | `steep` | `NFR-3`: `steep check` over six named targets, `core` strict | a type error in any target | | 5 | `test:gems` | `NFR-5`, `NFR-6`, `NFR-10`: every gem's suite in one `ruby -w -W:deprecated` process, stderr scanned for `warning:`, SimpleCov `minimum_coverage 80` over `gems/*/lib/**/*.rb` | a failing test, a warning at load or run time, a leaked thread, coverage below 80 | | 6 | `test:gates` | `NFR-17`: the gate suites under `test/gates/`, which drive every other gate against its failing fixtures | any fixture a gate stops rejecting | @@ -22,8 +22,8 @@ everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T | 9 | `gates:clean_bundle` | `SEAM-1`, `NFR-1`, `NFR-10`: each gem loads inside a scratch bundle holding only itself (and core, for an adapter); Bundler refuses every undeclared gem | a `require` of a bundled gem nobody declared — `logger` on Ruby 4.0 | | 10 | `gates:rbs_surface` | `NFR-11`: no constant outside `Dexpace::` and a stdlib allowlist in any public signature. The signatures are loaded as one set and every name resolved against it, the way `rbs validate` does, so a relative `Headers` inside `module Dexpace` and a type variable `T` are not leaks; every type tree is then walked, so a leak inside an optional, a union, a tuple, a record, a proc or a type argument is seen | `Async::Task` as a return type, superclass, mixin (name or type argument), type alias, class or module alias, class- or method-level generic bound, or anywhere inside a type; `DexpaceX::Thing`; a set that does not resolve | | 11 | `gates:sig_diff` | `NFR-4`: every public declaration in `sig/`, parsed and flattened to one line per overload with `private` honoured, against the last `v*` tag, per gem; a removed **or narrowed** declaration fails | a declaration or file gone since the tag, a dropped overload, a method moved under `private` (vacuous, by stated design, until the first tag) | -| 12 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers and singleton methods included, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act | -| 13 | `gates:single_instance` | design §2.4: one resolved path per core file; `Dexpace::VERSION` equals the gemspec | one core feature resolved from two directories; a version literal that disagrees | +| 12 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers (those still public on the model) and singleton methods included, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act | +| 13 | `gates:single_instance` | design §2.4: one resolved path per core file; `Dexpace::VERSION` equals the gemspec. A second copy of core cannot finish loading once core holds `Data.define` models — Ruby refuses the re-opened class with `superclass mismatch` — and the gate reports that in the same terms, beside the features loaded twice before it | one core feature resolved from two directories; a version literal that disagrees | | 14 | `gates:versions` | `NFR-14`, `NFR-10`: `.ruby-version`, the CI matrix, six gemspec versions and floors and six `VERSION` literals all agree with `VERSIONS` | any consumer that drifted; a gemspec that does not load | | 15 | `gates:reproducible` | `NFR-12`: each gem built twice under a fixed `SOURCE_DATE_EPOCH` is byte-identical | a gemspec whose build depends on anything but its inputs | | 16 | `yard` | styleguide 14.1: every public object documented | `yard stats --list-undoc` below 100% | diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 2024578..264e16e 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -2327,6 +2327,18 @@ design. **Code half: the two lines and the four files.** Touches `NFR-7`, `NFR-13`. Added after phase 10's planning pass, so it is the thirty-third bullet and is not yet in its design's disposition table; phase 10 dispositions it at execution. +- **`rake rubocop` passes vacuously in a worktree nested under the parent checkout's `.claude/`.** Found by + phase 1's implementation on 2026-09-14, the first time the gate ran from such a worktree: RuboCop takes + `AllCops/Exclude` from the topmost `.rubocop.yml` on the path — here the parent checkout's, whose + `.claude/**/*` line (the P0-11 exception above) contains the whole worktree — so `bundle exec rake rubocop` + inspected 8 files of 126 and reported no offenses on a tree that, inspected honestly with + `--ignore-parent-exclusion`, had two. CI and a plain checkout have no parent `.rubocop.yml` and are + unaffected; the exposure is the agent worktree layout this repository actually uses. The repair is one + token in the strict direction — `--ignore-parent-exclusion` on the gate's command in `tasks/quality.rake`, + with `test/gates/rubocop_config_test.rb` asserting it — and it is a change to a phase-0 gate, so it is + phase 10's. **Code half: the one flag and its assertion.** Touches `NFR-7`, `NFR-17`. Added after phase + 10's planning pass, so it is the thirty-fourth bullet and is not yet in its design's disposition table; + phase 10 dispositions it at execution. **2026-09-13** — **Execution order amended by the roadmap-level generator-fitness review, which read the plan end to end against one question: will a generated OpenAPI client be able to use this?** No cell of @@ -2551,3 +2563,32 @@ owning branch, every gate change with a fixture that turns it red (checklist dev The two counts that changed: `gems/` from zero to six; the phase-directory count is unchanged at eleven, since every directory already existed as planning documents. `CLAUDE.md`'s "After scaffold — planned" block is rewritten from what was built. + +**2026-09-15** — **Phase 1 implemented**, as three stacked branches against issue #8: code, tests, +documentation. `dexpace-core` now carries the HTTP domain model — twenty-two new `lib/` files under +`lib/dexpace/`, each with its `sig/` and `test/` mirror, exactly the layout the design's Module Layout +section names — and the other five gems are still phase-0 skeletons at `0.0.0`. The checklist is at +`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md`: forty-two rows, 36 ✅ (`HTTP-3` +split three ways, `HTTP-46`'s body half deferred to 3b), 4 ⏳ (`HTTP-22`, `HTTP-48`–`HTTP-50`, all under +`docs/first-release.md`), none 🚫. `bundle exec rake` is green on 4.0.6 with 100% line coverage against the +80% floor; the matrix set is green on 3.2.11 and `test:gems` on 3.3.12 and 3.4.10 as well. **The two +interpreter findings the design was built on held**: `Model#with` is proven load-bearing by removing it and +watching the floor fail while 4.0.6 passes, and every validator reads bytes, so `"a\0"`, `"a\r\nb"` and +invalid UTF-8 are rejected with the SDK's error rather than the regexp engine's. **One finding reversed a +ledger row**: `Request` and `Response` *are* `Ractor.shareable?` as built, because `URL.parse!` had to freeze +the URI's component Strings anyway (`URI#freeze` is shallow and `URI#dup` shares them, an `XCUT-15` alias +the plan's `dup.freeze` left open) and `URI::RFC3986_PARSER` is already frozen by the uri gem on both +interpreters (given a `nil` or frozen body; the opaque body is carried as given) — `P1-9` is retired, `P1-13` records the ownership rule, and the corpus note that carried +`P1-9` gains a correcting entry. Two more ledger rows added at implementation: `P1-11`, `HeaderName`'s fold +is a derived attribute rather than a second member, and `P1-12`, `Query` equality compares encodings, which +is `HTTP-30` stated literally. Seventeen departures from the plan's text are itemised in the checklist, none +lowering a gate; five are gate or tool corrections the first real signatures forced, each pinned by a fixture +on the tests branch: `gates:rbs_surface`'s stdlib list gains `Data`, `ArgumentError` and `StringScanner`; +`rbs:validate` loads the allowlisted stdlib signature sets; `gates:single_instance` survives — and names — +the `superclass mismatch` a second copy of a `Data.define` model raises; `tools/surface.rb` stops listing a +reader the model made private; and `.rubocop.yml` admits Steep's `#: Type` annotation, which strict Steep +requires on an empty literal. The four notes the design filed stand; the phase-10 inbound list gains a +thirty-fourth bullet, the vacuous `rake rubocop` in a nested worktree. The three postponed items keep their +owners. The counts that changed: `gems/` is still six but `dexpace-core` is no longer a skeleton; the +phase-directory count is unchanged at eleven; `CLAUDE.md`'s "no domain code" paragraph, its construction +pattern and its gem sentence are rewritten from what was built. diff --git a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md new file mode 100644 index 0000000..edd65db --- /dev/null +++ b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md @@ -0,0 +1,219 @@ +# Phase 1 — Core HTTP Domain Model: Checklist + +**Written at execution time, 2026-09-15, from what was built** — not from the plan. A row whose +task did not do what the plan said is a row that says so, and the "Deviations from the plan" +section below is where each departure is stated with its reason. + +Legend, verbatim from the roadmap's cross-cutting constraint 3: ✅ implemented and tested · +🚫 not built (permanent simplification, named reason) · ⏳ deferred (naming the plan task — phase, +task number and path — that will do it, or the `docs/first-release.md` entry that owns it) · +N/A not applicable in this port. + +Plan: `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model.md`. Task numbers are that +plan's. Design: `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md`, whose +Deviation Ledger rows `P1-n` are cited below. Every test file named here is under +`gems/dexpace-core/test/`, mirrors its `lib/` file one for one, and opens with the IDs it +exercises. + +## Requirement rows + +Forty-two: `HTTP-1`–`HTTP-35`, `HTTP-46`–`HTTP-50`, `HTTP-53` and `SEAM-29`. + +| ID | Level | Status | Task(s) | What was built, and where it is proven | +|---|---|---|---|---| +| `HTTP-1` | MUST | ✅ by construction | 2, 4–15 | Every model is `class X < Data.define(...)` including `Dexpace::Model`: frozen on construction, collections deep-frozen once through `Model.own`, `#with` the only derivation. `Ractor.shareable?` is asserted on every collection-holding model — `Headers`, `MediaType`, `Query`, `RequestOptions`, **and** `Request`/`Response` with a `nil` body (see `P1-9` as built, below; the opaque body is carried as given) — as the one-line proof the freeze reached every level (`dexpace/model_test.rb`, each model's suite) | +| `HTTP-2` | MUST | ✅ by construction, gap stated | 2, 14 | `private_class_method :new` plus a validating `.build` on every model. The residual gap is asserted, not hidden: `dexpace/http/request_test.rb` proves `Request.send(:new, …)` reaches the constructor and that `Request.new` does not, and names the mitigation — wire-boundary re-validation, phase 8a Task 16 / 8c Task 9 / phase 9 Task 7 (design §10.10). Because validation lives in `initialize`, a forged instance is still a *valid* one; the gap is bypassing the builder's cross-field defaulting, not validation | +| `HTTP-3` | MUST | ✅ / ⏳ / vacuous — split three ways | 5, 11, 13, 14, 15 | ✅ for the five models phase 1 builds: `Headers#new_builder` dups every value list and carries the direction, `Query#new_builder` dups every pair, `RequestOptions#new_builder` dups the tags, `Request#new_builder` and `Response#new_builder` carry frozen members; each suite derives, mutates the builder, builds a second instance and asserts the first unchanged. Value types with no builder derive through `Model#with`, which routes through `.build`. ⏳ for **the multipart body**: phase 1 ships no body, so `newBuilder()`-style derivation for it is `docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle.md` Task 7's. **`RequestConditions` is vacuous**: the type is `HTTP-50`'s and `HTTP-50` is ⏳ under `docs/first-release.md`, so there is no instance for the clause to bind to | +| `HTTP-4` | MUST | ✅ | 1, 2, 5, 11, 13, 14, 15 | `Model.required!(name, value)` raises `Dexpace::InvalidArgumentError` with the one message form ` is required`; every model's `initialize` calls it for each required member — `Request` for method, url and headers, `Response` for request, protocol, status and headers in `HTTP-4`'s own order — so the field is named whether the model was reached through `.build`, `#with` or a builder (`dexpace/model_test.rb`; the "names a missing member" cases in `request_test.rb`, `response_test.rb`, `headers_test.rb`, `query_test.rb`, `request_options_test.rb`) | +| `HTTP-5` | MUST | ✅, two tiers | 2, 5, 11, 13 | Collections are `Model.own`ed — `Ractor.make_shareable(x, copy: true)` — once at construction. Appendix C's two tiers are implemented and asserted in opposite directions: `Headers#names`/`#entries` and `Query#names`/`#entries` are a fresh frozen snapshot per call (`refute_same` on two calls), `Headers#[]` is the instance's own frozen list (`assert_same`), and `Query#[]` allocates and freezes because a pair list has no stored per-name list. Mutating any returned collection raises `FrozenError`; a snapshot taken before a builder mutation is unchanged after it (`P1-7`) | +| `HTTP-6` | MUST | ✅ | 14, 15 | `Request` is exactly `Data.define(:method, :url, :headers, :body)`; `Response` exactly `(:request, :protocol, :status, :reason, :headers, :body)`; `to_h.keys` is asserted in both suites; `headers` is a `Headers`, never nil, `Headers::EMPTY` / `Headers::EMPTY_INBOUND` by default; operational knobs live in `RequestOptions` | +| `HTTP-7` | MUST | ✅ | 7, 14 | `Method#body_forbidden?` over the frozen set `{GET, HEAD, TRACE, CONNECT}`; `Request#initialize` rejects a non-nil body on such a method, and `Request::Builder#build` rejects it first with the same message. Proven for all four tokens, and for derivation: `get_request.with(body: "payload")` raises (`request/builder_test.rb`, `request_test.rb`) | +| `HTTP-8` | SHOULD | ✅ | 14 | `Request::Builder#build`: no method and no body → `Method::GET`; a body with no method → `method is required`, checked before `HTTP-7` so the missing method is what is reported (`request/builder_test.rb`) | +| `HTTP-9` | MUST | ✅, cites `P1-10` | 7 | `Method::IDEMPOTENT = %w[GET HEAD OPTIONS PUT DELETE]` is the single source and `#idempotent?` reads it; the canonical wire token equals the uppercase name structurally, since `Method.of` upcases (no argument) and validates the RFC 7230 token grammar through `HeaderSyntax.token?`. The set and predicate are **public**, which the requirement's parenthetical says they should not be — `P1-10`, because phase 6a/6b read them by receiver from sibling files (`method_test.rb`) | +| `HTTP-10` | MUST | ✅ | 6 | `Status.of(code)` is total over 100–599 — a 256-sample property test — and never raises for an unrecognised code; `Status.canonical_name(code)` and `#canonical_name` are the separate lookup and answer `nil` for a vendor code (499, 520, 526, 530, 599 asserted). A non-Integer or an out-of-range Integer is a caller mistake and raises (`status_test.rb`) | +| `HTTP-11` | MUST | ✅ | 6, 15 | Six range predicates on `Status`, derived from the code; `Response` exposes the same six as one-line delegations (`status_test.rb`, `response_test.rb`) | +| `HTTP-12` | MUST | ✅ | 6 | `Status` has one member, `code`; equality and hash are `Data`'s over it, so `Status.of(200) == Status::OK` and both hash identically, with the name a lookup rather than a member (`status_test.rb`) | +| `HTTP-13` | MUST | ✅ | 3, 4, 5 | `HeaderName#folded` is `downcase` with no argument (`Dexpace/NoLocaleCaseFold` rejects the argument form repository-wide); `Headers` keys `values` and `casing` by the fold for storage, lookup, containment, mutation, removal, equality and hashing; `Headers#==`/`#hash` are over the folded values only (`P1-8`). The fold never meets a non-ASCII byte because `HTTP-17` rejected it first (`header_name_test.rb`, `headers_test.rb`) | +| `HTTP-14` | MUST | ✅ | 5 | `Headers::Builder#add` appends, `#set` replaces the whole list; per-name insertion order is the array's (`headers/builder_test.rb`) | +| `HTTP-15` | MUST | ✅ | 5 | `Headers::Builder#set(name, nil)` removes the header entirely — no value list, no casing entry (`headers/builder_test.rb`) | +| `HTTP-16` | SHOULD | ✅ | 5 | Ruby's insertion-ordered `Hash`; `#names` and `#entries` come back in insertion order with original casing, and the first casing added is the one emitted (`headers_test.rb`, `headers/builder_test.rb`) | +| `HTTP-17` | MUST | ✅ | 3, 4, 5 | `HeaderSyntax.trim` removes SP and HTAB only, on bytes (`P1-5`: `String#strip` strips NUL); `valid_name?` rejects empty, every byte below 0x21, DEL and every byte ≥ 0x80 by reading `name.b.each_byte`, so `"a\0"`, `"a\r"`, `"a\r\nb"`, `" "`, an interior space and invalid UTF-8 are all rejected with the SDK's error, never the regexp engine's; `" X-Trace "` is accepted as `X-Trace`. `HeaderName#initialize` and `Headers#initialize` run it again on every stored name, so `.build` and `#with` meet it too (`header_syntax_test.rb`, `header_name_test.rb`, `headers_test.rb`) | +| `HTTP-18` | MUST | ✅ | 3, 5 | `HeaderSyntax.valid_outbound_value?`: HTAB plus 0x20–0x7E, read as bytes; `"v\xC3\xA5lue"` rejected, `"a\tb"` accepted. `Headers::Builder` and `Headers#initialize` apply it for `:outbound`. A 128-sample property test proves the outbound grammar is strictly narrower than the inbound one (`header_syntax_test.rb`, `headers_test.rb`) | +| `HTTP-19` | MUST | ✅ | 3, 5, 15 | `HeaderSyntax.valid_inbound_value?` admits obs-text and refuses C0-except-HTAB and DEL; inbound names go through `validate_name!` unchanged. `direction` is a **member** of `Headers`, so `#new_builder` on an inbound model is inbound and `Headers::EMPTY_INBOUND` is `Response::Builder`'s default — asserted both ways, an inbound model deriving a lenient builder and an outbound one still refusing obs-text (`headers_test.rb`, `response_test.rb`) | +| `HTTP-20` | MUST | ✅ | 3, 5 | A rejected value never appears in a message in any form (`"secret\r\ntoken"` under `Authorization`: the message names the header and not `secret`); a rejected name is echoed with its control bytes escaped as `\xNN` through `HeaderSyntax.escape`, which itself reads bytes (`header_syntax_test.rb`, `headers/builder_test.rb`) | +| `HTTP-21` | MUST | ✅ | 4, 5 | `HeaderName` compares and hashes by `#folded`, keeps `#original` for emission, interoperates with the string-keyed API (`HeaderName.of` accepts a `String` or a `HeaderName`; every `Headers` method accepts either), and enforces `HTTP-17`. The fold is a derived attribute set before `super`, not a second member (`P1-11`) (`header_name_test.rb`) | +| `HTTP-22` | MAY | ⏳ | — | Not built; the observable contract — value equality by folded name — holds without interning. Owned by `docs/first-release.md` § Blockers before first publish, the `HTTP-22`/`48`/`49`/`50` decision line | +| `HTTP-23` | MUST | ✅ | 9 | `MediaType.parse` lower-cases type, subtype and every parameter key and preserves each value's case; `initialize` re-checks that every component is already folded, so `#with(type: "TEXT")` raises. Equality falls out of the members: case-insensitive where folded, case-sensitive on values (`media_type_test.rb`) | +| `HTTP-24` | MUST | ✅ | 9 | `MediaType#charset` looks `charset` up under the folded key, folds the value, and returns it only when this Ruby's `Encoding.name_list` (minus the four process-relative pseudo-aliases) knows it; `nil` otherwise, never a raise; the `nil` is documented at the accessor (`media_type_test.rb`) | +| `HTTP-25` | MUST | ✅ | 9 | A `StringScanner` parser over the byte-validated input: parameters split on `;` outside a quoted-string, each on its **first** `=`, quotes stripped and quoted-pairs unescaped; `#render` emits a value bare when it is a token and quoted-and-escaped otherwise. `parse(render(x)) == x` is a 64-sample property over an alphabet including `;`, `=`, `"` and `\` (`media_type_test.rb`) | +| `HTTP-26` | MUST | ✅ | 3, 9 | `MediaType.parse` runs `HeaderSyntax.valid_outbound_value?` — the same predicate, not a copy — on the raw input before any character-oriented work, and `initialize` runs it on every parameter value; CR, DEL, non-ASCII and invalid UTF-8 are rejected with the SDK's error (`media_type_test.rb`) | +| `HTTP-27` | SHOULD | ✅ | 9 | `#matches?`: `*/*` matches everything, `type/*` any subtype of the type, parameters ignored; `*/json` is rejected at construction (`media_type_test.rb`) | +| `HTTP-28` | MUST | ✅ | 11 | `Query` is a list of `[name, value]` pairs: case-sensitive names, insertion order across names, multiple values per name, and `Query::Builder#add(name, nil)` stores `""` — one empty-string value, distinct from an absent name (`query_test.rb`, `query/builder_test.rb`) | +| `HTTP-29` | MUST | ✅ | 10, 11 | `Query#encode` renders every name and value through `PercentEncoding.encode_component`, one occurrence per value, in insertion order, no leading `?`, `""` when empty (`query_test.rb`) | +| `HTTP-30` | MUST | ✅ | 11 | `Query#==`/`#hash` compare the encodings, which is the requirement stated literally (`P1-12`); order-sensitive, and `parse(q.encode) == q` is a 64-sample property. An empty value list cannot reach the model: `Query::Builder#set(name, [])` removes the name (`query_test.rb`, `query/builder_test.rb`) | +| `HTTP-31` | MUST | ✅ | 10, 11 | `Query.parse`: `nil` or blank → `Query::EMPTY`, a leading `?` tolerated, a segment with no `=` or a trailing `=` → `""`, a stray `&` skipped, a malformed escape kept raw; `PercentEncoding.decode_component` is lenient and total. Both read bytes, so a query carrying invalid UTF-8 parses and re-encodes byte-exactly (`query_test.rb`, `percent_encoding_test.rb`) | +| `HTTP-32` | SHOULD | ✅ | 10 | `PercentEncoding.encode_component`: unreserved set exactly `A-Za-z0-9-._~`, everything else uppercase-escaped — `a b*~+/!()'` → `a%20b%2A~%2B%2F%21%28%29%27` byte for byte; `decode_component("a+b")` is `"a+b"`; `%FF` round-trips (`percent_encoding_test.rb`) | +| `HTTP-33` | MUST | ✅ | 8 | `Protocol` over the canonical forms `http/1.1` and `http/2`; `Protocol.parse` accepts those and the aliases `HTTP/2` and `HTTP/2.0` case-insensitively (`downcase`, no argument, after a byte check) and raises naming an unrecognised identifier; `.build` accepts only a canonical form, so `#with` cannot admit an alias (`protocol_test.rb`) | +| `HTTP-34` | MUST | ✅ | 13 | `RequestOptions` — `timeout` (a `Float` of seconds), `max_retries`, `tags` — every field `nil`/empty by default, `RequestOptions::EMPTY` one shared frozen instance, `tags` `Model.own`ed at build and a map of `String` to `String` (`request_options_test.rb`) | +| `HTTP-35` | MUST | ✅ | 13 | In the model's `initialize`, so `.build` and `#with` meet it: a non-nil timeout that is zero or negative raises, a negative `max_retries` raises, `0` is accepted and means "disable retries for this call" (`request_options_test.rb`) | +| `HTTP-46` | MUST | ✅ (URL and method/headers), ⏳ (body by value) | 12, 14 | `Request#==`/`#hash` compare `URL.external_form(url)` — a textual key; Ruby's `URI` resolves nothing, and the test compares two textually different URLs naming one host without touching the network — plus method, headers and body by `==` (`P1-6`). The body half is opaque here and was built by phase 3b (`P3-15`, `docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle.md`) (`url_test.rb`, `request_test.rb`) | +| `HTTP-47` | SHOULD | ✅ | 12, 14 | `URL.parse!` pins `::URI::RFC3986_PARSER`, refuses a non-absolute URI naming it, and converts `URI::InvalidURIError` into a `Dexpace::InvalidArgumentError` carrying the offending input with the original as `cause`; `Request::Builder#build` and `Request#initialize` both route the URL through it, so `"::bad"` is named from either (`url_test.rb`, `request/builder_test.rb`) | +| `HTTP-48` | SHOULD | ⏳ | — | Not built; `docs/first-release.md` § Blockers before first publish, the `HTTP-22`/`48`/`49`/`50` decision line — no v1 phase constructs a conditional request | +| `HTTP-49` | SHOULD | ⏳ | — | As `HTTP-48` | +| `HTTP-50` | SHOULD | ⏳ | — | As `HTTP-48`; this is also why `HTTP-3`'s `RequestConditions` clause is vacuous | +| `HTTP-53` | MUST | ✅ | 9 | `MediaType.parse` rejects blank input, a missing or empty type or subtype, more than one `/`, a parameter with no `=`, an empty key or an empty value, an unterminated quoted-string and text after a closed one; the rejected list is enumerated in `media_type_test.rb` | +| `SEAM-29` | MUST | ✅, both halves | 1, 2, 5, 11, 13, 14, 15 | The uniform message: `Model.required!` is the one helper, one error class, one form (`model_test.rb`). The generic contract: `Dexpace::Builder`, included by all five builders, with `Dexpace::_Builder[T]` as the RBS interface and `Builder.build_all` as the composition helper; `dexpace/builder_test.rb` passes all five builders through it — the requirement's conformance step — and proves an incomplete builder raises `NotImplementedError` and a non-builder is refused | + +Forty-two rows: 36 ✅ (three of them by construction or split — `HTTP-1`, `HTTP-2`, `HTTP-3`; two carrying a +deferred half — `HTTP-3`, `HTTP-46`), 4 ⏳ (`HTTP-22`, `HTTP-48`, `HTTP-49`, `HTTP-50`), 0 🚫, 0 N/A. +`XCUT-15` and `XCUT-18` are implemented here — `Model.own`/`Model.frozen_string` and `HeaderSyntax` — and +are phase 9's to disposition; neither is a row. + +## What was built + +Twenty-two new `lib/` files under `gems/dexpace-core/lib/dexpace/`, each with its `sig/` mirror and its +`test/` mirror, plus the entry file, its signature and the smoke suite extended — the layout the design's +Module Layout section names, file for file. `require "uri"` and `require "strscan"` are the only two +`require`s outside `require_relative` (the plan expected `uri` alone; `strscan` is the media-type parser's, +and both are on the require allowlist). The gemspec still has zero `add_dependency` lines. + +The gates, all seventeen, on **4.0.6** (`bundle exec rake`, 2026-09-15): green, exit 0 — `cops:test` 65 runs, +`steep` no type error over the strict `core` target, `test:gems` 252 runs / 1853 assertions with **100.00% line +coverage (768/768)** against the 80% floor, `test:gates` 128 runs / 523 assertions, the nine `gates:*` tasks, +`yard` 100.00% documented (15 modules, 16 classes, 60 constants, 8 attributes, 122 methods), `bundler_audit` +clean. **One caveat about `rubocop`, stated in full under "Findings routed"**: run through `rake` from this +worktree it inspected 8 files, because RuboCop inherits `AllCops/Exclude` from the topmost `.rubocop.yml` on +the path and the parent checkout's excludes `.claude/**/*`, under which this worktree sits; run as +`bundle exec rubocop --fail-level=convention --ignore-parent-exclusion` it inspected **126 files, no +offenses**, and that is the run these rows rest on. + +The matrix set — `test:gems gates:gemspec_audit gates:require_allowlist gates:clean_bundle +gates:single_instance` — green on **3.2.11**, and `test:gems` green on **3.3.12** and **3.4.10** as well, +each with its own lockfile resolved fresh. The 3.2.11 run is the one that proves `Dexpace::Model#with`: with +the override removed (`Dexpace::Model.send(:remove_method, :with)` after load) `model_test.rb` fails one case on +3.2.11 — `Sample.build(code: 1).with(code: nil)` returns a model with a nil code — and passes on 4.0.6, which is +the design's finding 1 reproduced against the built code. + +The surface manifest `test/fixtures/surface/dexpace-core.txt` grew from two lines to 221 through +`bundle exec rake surface:regenerate`, once, after the last task; the diff was read line by line and holds +exactly the constants and methods the twenty-two files define — `Headers::EMPTY_INBOUND` beside +`Headers::EMPTY`, `Method::IDEMPOTENT` (`P1-10`), and no private reader (deviation 10 below). `gates:sig_diff` +still prints "no release tag yet". + +## Audit groups run + +The phase-start pair first: `--section conflicts --brief` returns the six harvested conflicts, every one +`[overridden by notes/…]`; `--origin note --brief` returns those six and the later phases' notes, none of which +contradicts this plan. `--req` was run for each task's IDs before that task. The four groups the design named: + +| Group | Result at implementation | +|---|---| +| Public API surface | Clean, with one rule the plan's own text got backwards: `data-modeling/3775e9d7` says *never* `module_function`, and the plan's Task 3 aside said `module_function` "rather than `extend self`" citing that same key. The corpus and `.rubocop.yml`'s `Style/ModuleFunction: extend_self` agree; the three function modules use `extend self` (deviation 3). `api-design/e4fa3438`'s override-with-a-why-comment is used four times — `HeaderName`, `Headers`, `Query`, `Request` — one more than the design counted, for `Query`'s equality-by-encoding (`P1-12`) | +| RBS / Steep typing | Two facts phase 0 could not have seen, both about rbs's core signatures: `Data` declares no `initialize`, so a keyword `super` in any `initialize` override resolves to `BasicObject#initialize` and Steep rejects it — declared once on `Dexpace::Model` in RBS only (deviation 5); and `URI::RFC3986_Parser` is an empty class, so its `#parse` is untyped at the one call site. Strict Steep also refuses an unannotated `[]`/`{}` (`Ruby::UnannotatedEmptyCollection`), whose annotation syntax `#:` the RuboCop baseline rejected — `Layout/LeadingCommentSpace: AllowRBSInlineAnnotation: true` (deviation 7). `NFR-11`'s allowlist lacked `Data`, `ArgumentError` and `StringScanner` (deviation 6) | +| Minitest conventions | Clean. `testing/62f8f4ec`'s error-boundary pairing is applied to every builder — class, field-naming message, no partial side effect — and `testing/f36a19cd`'s bounded property tests exist for `HeaderName`'s fold, `Status`'s totality, `HeaderSyntax`'s grammar ordering, `PercentEncoding`, `MediaType` and `Query`. Three suites carry a nested test class per behaviour group (deviation 16), because `Style/OneClassPerFile` forbids a second top-level class and `Metrics/ClassLength` caps one at 100 lines | +| Encoding and binary strings | Clean and load-bearing: every validator reads `value.b.each_byte`; `PercentEncoding` accumulates into a BINARY buffer and retags UTF-8 at the end; `Query.parse` and `HeaderSyntax.trim` work on `.b`; `HeaderSyntax.validate_name!` retags a *valid* name with the caller's encoding because its bytes are proven printable ASCII, which is `io-and-byte-streams/0a4773a6`'s "retag only where the bytes are known to conform" (deviation 4) | + +The four notes the design filed are unchanged by implementation, with one correction filed as a fifth entry: +`docs/knowledge/notes/data-modeling.md` gains a `## Superseded` entry recording that `Request` and `Response` +**are** `Ractor.shareable?` as built, because `URL.parse!` freezes the URI's component strings it owns and the +uri gem already freezes `URI::RFC3986_PARSER` on 3.2.11 and 4.0.6 — the phase-1 entry's mechanism (shallow +`URI#freeze`, `make_shareable` freezing the global parser) was half right, and the half that was wrong is the +one the ledger row `P1-9` rested on. + +## Deviations from the plan + +Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate; three +change a gate or tool in the strict or the corrected direction, each with a fixture. + +1. **`Dexpace::Model#with(changes = nil)` takes a positional `Hash`, not `**changes`.** `Dexpace/NoKeywordSplat` + flags every public `def` in `gems/*/lib` with a `**` parameter, and an inline disable would special-case a + gate. Ruby passes keywords to a method that declares none as one positional Hash on every Ruby in the range + (probed on 3.2.11 and 4.0.6), so `x.with(code: 2)`, `x.with` and `x.with(**{})` all behave as the plan's + form does, and the empty call allocates nothing. +2. **`HeaderName` is `Data.define(:original)` with `folded` a derived attribute set before `super`**, and + `.build(original:)` has no `folded:` keyword. The plan's `initialize(original:, folded: nil)` leaves + `folded` unused, which `Lint/UnusedMethodArgument` refuses; and a derived value that is also a member can be + handed in stale — `#with(original: …)` carries the old fold in `to_h` — which is the argument `Status`'s + one-member design already makes. Setting the ivar before `super` is the one moment a `Data` is mutable, and + on 3.2.11 the inherited `Data#with` leaves it `nil`, which makes `Model#with` load-bearing for this type on + the floor and the floor run proves it. Ledger row `P1-11`. +3. **`extend self`, never `module_function`**, in `HeaderSyntax`, `PercentEncoding` and `URL`; private helpers + under a `private` section with a private RBS declaration. The corpus rule the plan cited says the opposite of + what the plan's aside claimed, and `Style/ModuleFunction` enforces the corpus. +4. **`HeaderSyntax.trim` returns BINARY bytes; `validate_name!` retags a valid name with the caller's + encoding**; `HeaderSyntax.token?` and `TCHAR` live in `HeaderSyntax`, shared by `Method` and `MediaType`, + rather than a `TCHAR` in `Method` alone. One grammar, one home. +5. **RBS: `module Model : _ModelInstance`** — an interface asking for `to_h` and an untyped `class`, because a + module cannot name every includer's own `.build` keyword list and Steep checks the self-type at every + `include`; and **`Model#initialize: (**untyped) -> void` declared in RBS only**, because rbs core declares no + `Data#initialize` and `Model` sits between every model and `Data` in the ancestor chain. Every model is + `class X < Data` in RBS with `private def self.new` and a typed `initialize`. +6. **`tools/rbs_surface.rb`'s `STDLIB_ALLOWED` gains `Data`, `ArgumentError` and `StringScanner`.** The first + real signatures — `class HeaderName < Data`, `class InvalidArgumentError < ::ArgumentError`, a private + helper taking a `StringScanner` — turned `gates:rbs_surface` red on correct code: the three are core Ruby or + `strscan`, which the require allowlist admits. Fixture `test/fixtures/gates/rbs_surface/gems/fixture/sig/core_superclasses.rbs` + and its case in `test/gates/rbs_surface_test.rb`; verified red under the previous list, green under this one. +7. **`.rubocop.yml`: `Layout/LeadingCommentSpace: AllowRBSInlineAnnotation: true`.** Strict Steep refuses an + empty collection literal with no `#: Type` annotation, and the cop's default rejects the annotation's + space-less `#:`. Every ordinary comment is still held to the space rule. +8. **`rbs:validate` loads the allowlisted stdlib signature sets with `-r`**, `RequireAllowlist::ALLOWED` minus + `set` (which rbs 4 ships under `core/`, as the Steepfile already records). With `--no-collection` and no + `-r`, `URI::Generic` and `StringScanner` were "not found" by a gate that had never met a stdlib reference. +9. **`gates:single_instance` rescues `superclass mismatch` and reports it as a double load.** A second copy of + core cannot finish loading once core holds `Data.define` classes — the second copy's `Data.define` is a fresh + anonymous superclass — so the gate's tally was never reached and its test saw a stack trace. The crash is the + violation itself; the gate now names it beside the features already loaded twice, and + `test/gates/single_instance_test.rb` asserts both. +10. **`Surface.data_readers` honours the model's visibility.** The walker took the anonymous `Data` superclass's + public readers, which stay public there when the model makes them private (`Headers`'s `values`/`casing`, + `Query`'s `pairs`), so the first manifest listed three methods `respond_to?` denies. Fixture `Hidden` and a + case in `test/gates/surface_snapshot_test.rb`. +11. **`Request`'s `Data.define(:method, …)` line carries `# rubocop:disable Lint/DataDefineOverride`** with the + reason above it. The cop is `pending` — enabled only through `NewCops: enable` — and it warns that a member + named `method` "may be unexpected"; `HTTP-6` fixes the member by name and the design documents the shadowing + of `Object#method` deliberately, so the warning is answered in place. The one inline directive in the tree. +12. **`URL.parse!` re-parses a URI input from its text and freezes the component strings, rather than + `dup.freeze`.** `URI::Generic#freeze` is shallow and `dup` shares `@host`/`@path` with the caller's object + (probed on both interpreters), so the plan's form aliased externally-mutable state through `request.url.host` + (`XCUT-15`). Consequence: **`Request` and `Response` are `Ractor.shareable?`** without any global touched, + which retires the narrowing in `P1-9`; both suites assert shareability (with a `nil` body — the opaque + body is carried as given, so an unfrozen one is the caller's). Ledger row `P1-13`. +13. **The media-type parser is a `StringScanner` over per-pattern-timeout regexps**, not the plan's character + loops, which tripped `Metrics/ClassLength`, `AbcSize` and the complexity cops. Every regexp runs only after + `HeaderSyntax` proved the input printable ASCII, so none meets invalid UTF-8. It is lenient about a bare + non-token value (`q=a b` parses, and `#render` quotes it back), which `HTTP-53`'s list does not forbid. +14. **`Query#==`/`#hash` compare encodings** rather than `Data`'s generated equality over the pair list, because + two Strings with the same non-ASCII bytes under different encoding tags are not `String#==` yet encode to + one wire query, and `HTTP-30` is stated in terms of the encoding. Ledger row `P1-12`. +15. **Eager type checks the plan did not have**: `Method.of` accepts `String` or `Symbol` and refuses anything + else; `HeaderName` requires a `String`; `Headers.build` requires a list of Strings per name; the builders' + writers refuse a wrong-typed value where they are set. Each pre-empts a `NoMethodError` that would otherwise + escape `rescue Dexpace::Error` — the Task 1 rule applied. +16. **Test layout**: every domain test file `require "dexpace"` itself (the shared helper cannot, or the smoke + suite's namespace snapshot would be vacuous); `headers_test.rb`, `query_test.rb` and `response_test.rb` nest a + second (and third) test class per behaviour group; the smoke suite snapshots the top level after + `require "uri"` and `require "strscan"`, whose constants are theirs. Run counts exceed the plan's expected + numbers throughout. +17. **Minor defaults**: `Request.build(body: nil)` and `Response.build(reason: nil, body: nil)` default their + optional members; `MediaType.build(parameters: {})` defaults; `Protocol.parse` and `MediaType.parse` require a + `String`. + +## Findings routed + +- **`rake rubocop` is vacuous in a worktree nested under the parent checkout's `.claude/`.** RuboCop takes + `AllCops/Exclude` from the topmost `.rubocop.yml` on the path — here the parent's, whose `.claude/**/*` line + (P0-11) swallows the whole worktree — so the gate inspected 8 files and passed on this tree. CI and a plain + checkout are unaffected; the honest local run is `--ignore-parent-exclusion`. A repair to a phase-0 gate is + phase 10's: routed to phase 10's inbound list in `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md`. +- **`P1-9` is retired as built** (deviation 12) — recorded in the design's Deviation Ledger as `P1-13` beside an + as-built note on `P1-9`, and as a corpus note; `docs/deviations.md` is phase 10's to flip and is untouched. +- **`Dexpace::Protocol` has no alias for `http/1.0`** — already on phase 10's inbound list from phase 8a; phase 1 + builds `HTTP-33` as stated and leaves the widening to that decision. + +## Postponed work + +The three items phase 1 postponed keep the owners the design's "Work Phase 1 Postponed, and Who Owns It Now" +section records — the suppressed trail (phase 4b, Task 1), wire-boundary re-validation (phase 8a Task 16, 8c +Task 9, phase 9 Task 7) and the `body` member's type with `HTTP-46`'s by-value half (phase 3b, `P3-15`) — and +were re-checked on 2026-09-15: `grep -n 'HTTP-48' docs/first-release.md` still finds the `HTTP-22`/`48`/`49`/`50` +decision line under § Blockers before first publish. The implementation postponed nothing further. diff --git a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md index b9af6be..a877153 100644 --- a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md +++ b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md @@ -897,9 +897,12 @@ Each row is consolidated into design §10 and audited by `docs/deviations.md`. | P1-5 | `HTTP-17`'s "surrounding whitespace" is read as SP and HTAB only | `HTTP-17` | `String#strip` also strips NUL, so the obvious implementation accepts `"a\0"`. Trimming CR or LF would launder a header-splitting payload into a valid name | | P1-7 | `HTTP-5` is implemented in **two tiers** — a fresh per-call snapshot for the name set and the entry set, the model's own frozen list per name — where design §4 says "the same frozen reference is returned from every accessor" | `HTTP-5`; design §4, §10.11 | Appendix C's own text splits them: "Name-set and entry-set accessors return a fresh per-call snapshot; per-name value-list accessors return the instance's own list." Design §4's sentence is a summary of the second tier, the specification is normative, and both halves are asserted so neither can be collapsed later | | P1-8 | `Headers#==`/`#hash` compare folded names and values only; casing and direction take no part | `HTTP-13`; `api-design/e4fa3438` | `HTTP-13` puts equality and hashing under the fold. `Data` would generate equality over all three members, making `Accept` and `ACCEPT` unequal — the exact confusion the requirement exists to remove | -| P1-9 | `Request` and `Response` are frozen and deep-frozen in their collections but are **not** `Ractor.shareable?` | design §4; `data-modeling/996c0b12` | The design's "deep-freezing at construction makes the whole wire model Ractor-shareable" holds for every model whose members are strings and collections, and not for the two that hold a `URI::Generic`: verified on 3.2.11 and 4.0.6, a frozen URI is not shareable because `@host` and `@path` stay unfrozen, and `Ractor.make_shareable(uri)` reaches shareability only by deep-freezing `URI::RFC3986_PARSER` — a process-global object — in place, which is the one thing §4 says never to do. `Ractor` is load-bearing nowhere (§4, §9), so the claim is narrowed rather than the global frozen. Filed as a corpus note | +| P1-9 | `Request` and `Response` are frozen and deep-frozen in their collections but are **not** `Ractor.shareable?` — **retired as built, 2026-09-15; see P1-13** | design §4; `data-modeling/996c0b12` | The design's "deep-freezing at construction makes the whole wire model Ractor-shareable" holds for every model whose members are strings and collections, and not for the two that hold a `URI::Generic`: verified on 3.2.11 and 4.0.6, a frozen URI is not shareable because `@host` and `@path` stay unfrozen, and `Ractor.make_shareable(uri)` reaches shareability only by deep-freezing `URI::RFC3986_PARSER` — a process-global object — in place, which is the one thing §4 says never to do. `Ractor` is load-bearing nowhere (§4, §9), so the claim is narrowed rather than the global frozen. Filed as a corpus note. **As built:** the first half is right and the second was not needed — `URL.parse!` freezes the component strings it owns (P1-13), and `URI::RFC3986_PARSER` is already frozen by the uri gem on both interpreters, so both models are shareable with no global touched — given a `nil` or frozen body, which is opaque and carried as given in this phase — and design §4's original claim stands; both suites assert it, and the corpus note is corrected by a second entry | | P1-10 | `Method::IDEMPOTENT` and `#idempotent?` are **public API** — a constant in `sig/` and a row in the `NFR-4` surface manifest — where `HTTP-9` says "the idempotent set is an internal constant rather than a public accessor on the method type" | `HTTP-9` | The requirement's substantive half — one set, the single source both the retry allow-list and the replay-safety gate derive from — is satisfied exactly, and nothing re-lists the five names. Only its shape parenthetical is departed from, because the two consumers live in sibling files and read it by receiver: phase 6a's `Resilience::Resend` calls `request.method.idempotent?` and phase 6b asserts against `Method::IDEMPOTENT` to prove it is *not* conflated with the redirect set. A private predicate cannot be called with an explicit receiver, so the alternative is a second copy of the set in `Resend` — the one thing `HTTP-9` exists to forbid. Recorded rather than reversed, because `NFR-4` locks the name at the first release tag | | P1-6 | `Request#==`/`#hash` override `Data`'s generated equality | `HTTP-46`; `api-design/e4fa3438` | `HTTP-46` requires comparison by textual external form; the generated equality would compare `URI::Generic` objects by `URI`'s own normalising rules, which is a different relation. The override carries the why-comment the styleguide requires | +| P1-11 | `HeaderName` is `Data.define(:original)`; the fold is a derived attribute set before `super`, not a second member, and `.build(original:)` takes no `folded:` | this design's HeaderName section (`Data.define(:original, :folded)`); `HTTP-21` | Added at implementation, 2026-09-15. A derived value that is also a member has to be accepted and ignored — `#with(original: …)` carries the stale fold in `to_h` — which `Lint/UnusedMethodArgument` refuses and which is the argument `Status` already makes for keeping its name out of its members. Setting the ivar before `super` is the one moment a `Data` is mutable; on 3.2.11 the inherited `Data#with` leaves it `nil`, so `Model#with`'s route through `.build` is what makes the type sound on the floor, and the floor run proves it | +| P1-12 | `Query#==`/`#hash` compare the encodings, overriding `Data`'s generated equality over the pair list | `HTTP-30`; `api-design/e4fa3438` | Added at implementation, 2026-09-15. Two Strings holding the same non-ASCII bytes under different encoding tags are not `String#==` yet encode to one wire query; `HTTP-30` states equality as "equal iff they encode identically", so comparing the encodings is the requirement literally. The override carries the why-comment the styleguide requires | +| P1-13 | `URL.parse!` re-parses a URI input from its text and freezes the URI's component Strings, rather than `dup.freeze` | this plan's Task 12; `XCUT-15`; design §4 | Added at implementation, 2026-09-15. `URI::Generic#freeze` is shallow and `URI#dup` shares `@host`, `@path` and `@query` with the source (probed on 3.2.11 and 4.0.6), so the plan's `input.dup.freeze` left `request.url.host << "x"` able to change a model in place through a reader, and a caller's later mutation of their own URI able to reach it. Parsing from the text yields components the method owns, which it freezes without touching the caller's object or the process-global parser. The consequence retires P1-9 | ## Work Phase 1 Postponed, and Who Owns It Now diff --git a/gems/dexpace-core/README.md b/gems/dexpace-core/README.md index 8c17a98..54b309f 100644 --- a/gems/dexpace-core/README.md +++ b/gems/dexpace-core/README.md @@ -3,9 +3,12 @@ Part of the [dexpace Ruby SDK](../../README.md): an HTTP-client toolkit, not an HTTP client. This gem is the core: the domain model, the pipeline and every seam's contract. -**Status: skeleton at `0.0.0`.** Nothing is published yet, and `lib/` holds the namespace and a -`VERSION` constant and nothing else. The phase that fills it is named in the YARD block of -`lib/dexpace.rb`. +**Status: `0.0.0`, unpublished; the HTTP domain model is built.** `lib/` holds phase 1's wire +model -- `Dexpace::Request`, `Response`, `Headers`, `HeaderName`, `Status`, `Method`, `Protocol`, +`MediaType`, `Query`, `RequestOptions`, the `HeaderSyntax`, `PercentEncoding` and `URL` function +modules, and the construction contract `Dexpace::Model` / `Dexpace::Builder` under the error root +`Dexpace::Error` -- and nothing else yet: the pipeline, the seams and the body model are later +phases'. The as-built page is `docs/sdk-documentation/http.md`. ## Install @@ -19,9 +22,19 @@ gem "dexpace-core" ```ruby require "dexpace" -puts Dexpace::VERSION # => "0.0.0" +builder = Dexpace::Request.builder +builder.url = "https://api.example.test/widgets" +request = builder.header("Accept", "application/json").build + +request.method # => Dexpace::Method::GET, defaulted because there is no body +request.headers["accept"] # => ["application/json"], looked up case-insensitively +request.with(body: "x") # raises Dexpace::InvalidArgumentError: a GET carries no body ``` +Every type is frozen at construction, validated in its constructor, and derived through +`#with` or `#new_builder` rather than mutated; a caller mistake raises +`Dexpace::InvalidArgumentError`, which is also an `ArgumentError`. + ## Depends on Nothing. `dexpace-core.gemspec` has no `add_dependency` line, and `rake gates:gemspec_audit` asserts @@ -29,6 +42,7 @@ it stays that way (`SEAM-1`, `NFR-1`). ## Where to read next +- `docs/sdk-documentation/http.md` -- the domain model as built: what each type guarantees. - `docs/sdk-documentation/architecture.md` -- how the gems compose and which one to install. - `docs/sdk-design-ruby/02-gem-and-workspace-layout.md` -- the gem layout and the zero-dependency invariant every gem here is built under. From ba5f91eeacae1de7f7a445aa44a5e4ba2a6deefb Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 09:36:33 +0300 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20record=20the=20round-1=20review=20r?= =?UTF-8?q?epairs=20and=20rewrite=20the=20corpus=20note=20as=20a=20confirm?= =?UTF-8?q?ation=20of=20design=20=C2=A74?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/knowledge/notes/data-modeling.md | 6 +- docs/sdk-documentation/http.md | 9 ++- docs/sdk-documentation/quality-gates.md | 2 +- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 37 ++++++++--- ...phase1-core-http-domain-model-checklist.md | 64 +++++++++++++------ ...05-phase1-core-http-domain-model-design.md | 2 +- 6 files changed, 84 insertions(+), 36 deletions(-) diff --git a/docs/knowledge/notes/data-modeling.md b/docs/knowledge/notes/data-modeling.md index 9e5417f..5dc1a1f 100644 --- a/docs/knowledge/notes/data-modeling.md +++ b/docs/knowledge/notes/data-modeling.md @@ -9,11 +9,9 @@ entry's stable key. review · `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md` · high · sha:manual-roadmap-conflict-1 - **A seam is a duck type plus a `.conforms?` predicate plus an RBS interface type, never a Sorbet abstract module.** Resolves `data-modeling/a13e9ffe`. The rule is conditional on Sorbet in its mechanism — `sig { abstract }` stubs are `sorbet-runtime`, which would be an `add_dependency` line in `dexpace-core.gemspec` that `SEAM-1`/`NFR-1` forbid and `rake gates:gemspec_audit` mechanically rejects — so the premise is absent rather than overruled, exactly as `../notes/type-system.md` records for the wider Sorbet question. But the substitution is not simply "drop the module": the rule also says concrete implementations should `include` the interface, and this port deliberately does **not** ask that either, for a reason that is about libraries rather than about Sorbet. A seam here is implemented by a gem this project does not control and, in the common case, by an object that already has the shape — a bare `lambda`, a `Dexpace::Pipeline`, a Rack-style `#call`-able. `docs/sdk-design-ruby/00-porting-method.md`, working principle P14, makes that a principle: where the host ecosystem has converged on one API shape, the seam is defined as a structural subset of it so existing infrastructure duck-types in with zero adapter code. A nominal `include` would put a `Dexpace::` constant into every adapter's ancestry, would make a two-line lambda non-conformant, and would buy nothing Ruby actually enforces. What the SDK does instead, per `docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md` §3.2 and §3.4: each seam is three artifacts and no fourth — a documented duck type (`#call(request, options, cancellation)` for a transport; `#media_type`/`#dump_string`/`#dump_bytes`/`#dump_to`/`#dump_into`/`#load` for a codec); a `.conforms?` predicate on the seam's own module, which is the runtime check the registry runs before accepting a factory and which is also how `SEAM-2` is satisfied, because core names the *shape* and never an implementation; and an RBS `interface _Transport`-style type in that seam's `sig/` file, which is what a consumer's own `steep check` sees at a parameter. Assignability and runtime identity are different questions in Ruby and each artifact answers one — the same split phase 1 found for `SEAM-29`, where the RBS interface `_Builder[T]` and the module `Dexpace::Builder` are both needed. What is lost and is not replaced: a type checker cannot verify that an adapter *implements* the seam, only that it is passed where one is expected; `.conforms?` and `dexpace-conformance`'s per-adapter assertions are what stand in. One residual gap is admitted rather than hidden: the synchronous and asynchronous transport seams have the same structural shape and differ only in return type, so no `.conforms?` can tell them apart before the first call — they are two registries and the adapter names which it registers into. The styleguide-amendment alternative (making chapter 6's interface rule read "a documented duck type, an abstract module where Sorbet is in use") was considered and not taken; this is recorded as an SDK deviation instead (phase 2). review · `docs/work/mvp/phase2/2026-09-06-phase2-seam-foundations-design.md` · high · sha:manual-phase2-seam-duck-types +- **The whole wire model is `Ractor.shareable?` as built, `Request` and `Response` included: design §4's claim stands in full, and the phase-1 design's narrowing of it (ledger row `P1-9`) did not survive the build.** Two documents disagreed. `data-modeling/996c0b12` — design §4 — says deep-freezing at construction makes the whole wire model Ractor-shareable, and the phase-1 design's `P1-9` said the two models holding a `URI::Generic` are not. Design §4 won: this entry confirms `data-modeling/996c0b12` and overrules nothing harvested. What the phase-1 design got right, verified on 2026-09-05 and again on 2026-09-15 against 3.2.11 and 4.0.6: `URI::RFC3986_PARSER.parse("https://example.test/a?x=1").freeze` is `frozen?` but **not** `Ractor.shareable?`, because `URI::Generic#freeze` is shallow and its `@host` and `@path` Strings stay unfrozen — and that same fact is an `XCUT-15` alias rather than a shareability curiosity: `URI#dup` shares those Strings with the source, so the plan's `input.dup.freeze` left `request.url.host << "x"` able to change a model in place through a reader. What it got wrong: it took `Ractor.make_shareable(uri)` for the only route to a shareable URI and warned that the call deep-freezes `URI::RFC3986_PARSER`, a process-global object, in place — the in-place hazard `data-modeling/fa3eaf6e` and `data-modeling/59efaae6` name, one level further out than they state it. That warning is moot: `URI::RFC3986_PARSER.frozen?` and `Ractor.shareable?(URI::RFC3986_PARSER)` are already **true** on both interpreters, because the uri gem freezes the parser at definition, and the SDK never calls `make_shareable` on a URI in any case. What the SDK does: `Dexpace::URL.parse!` re-parses a URI input from its text (never `dup`, which aliases) and freezes each String-valued instance variable of the URI it now owns, skipping the parser reference; `Model.own` keeps every collection member a deep-frozen copy; `Model.frozen_string` copies and freezes every caller-supplied String member, `Response#reason` included; and so every model — `Request` and `Response` given a `nil` or frozen body, which is opaque in phase 1 and carried as given — is `Ractor.shareable?` with no global touched. `request_test.rb` and `response_test.rb` assert it, so a later change to `URL.parse!` cannot lose the property silently. `P1-9` is retired as built and `P1-13` records the ownership rule (`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md`). `Ractor` is still load-bearing nowhere (`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`'s runtime floor keeps it out of the supported surface). This entry stands where the phase-1 design's planning-time entry stood under `## Superseded` (sha:manual-phase1-ractor-uri), which claimed the narrowing and was withdrawn once the build disproved it; the phase 3 to 8 design documents that cite that entry's former key as "narrowing the shareability claim" describe the planning-time reading, and each phase's start-of-phase note query surfaces this entry instead. + review · `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md` · high · sha:manual-phase1-ractor-uri-as-built ## Superseded - **`Data#with` does not call an `initialize` override on Ruby 3.2, so it does not validate there; it does on 3.4 and 4.0.** Supersedes `data-modeling/c4fe4732`, whose "#with produces a copy with changes" was verified on 3.4.10 only. Re-verified on 2026-09-05 against three installed interpreters with one `Data` subclass whose `initialize` raises on a `nil` member: on **3.2.11** `instance.with(member: nil)` returns `#` and the override is never invoked; on **3.4.10** and **4.0.6** it raises. Why it matters rather than being a curiosity: `docs/sdk-design-ruby/04-domain-model-construction.md` makes `#with` the *only* derivation path for every value type with no builder — `MediaType`, `Status`, `Protocol`, `Method`, `HeaderName` and the conditional-request helpers — so following the harvested wording ships unvalidated derivation on the declared floor (`required_ruby_version >= 3.2`) while every `HTTP-4`/`SEAM-29` check passes on the developer's Ruby. That is the same shape as the `URI::DEFAULT_PARSER` split design §3.5 pins against: it passes where you look and fails where you do not. What the SDK does instead: `Dexpace::Model`, the module every core `Data` type includes, overrides `#with` to route through the type's own validating `.build` — `changes.empty? ? self : self.class.build(**to_h, **changes)` — so derivation re-validates identically on 3.2, 3.3, 3.4 and 4.0, and a value type added by any later phase inherits it by including the module rather than by remembering this entry. The CI matrix runs the real suite on every row, which is what keeps the guarantee standing; locally the check is `mise exec ruby@3.2.11 -- bundle exec rake test:gems`, and deleting the override turns that run red while the 4.0 run stays green. Two neighbouring facts in the same entry are unaffected and still hold on all three interpreters: a `Data` instance is frozen on construction, and `==`/`eql?`/`hash` are generated over all members. review · `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md` · high · sha:manual-phase1-data-with -- **The wire model is Ractor-shareable everywhere except where a member is a `URI::Generic`, and buying shareability there would deep-freeze a process-global parser.** Supersedes `data-modeling/996c0b12`, whose "deep-freezing at construction makes the whole wire model Ractor-shareable today" was verified on 3.4.10 against a `Data` holding a frozen `Hash` — which is true, and does not generalise to the two models that hold a parsed URL. Verified on 2026-09-05 against 3.2.11 and 4.0.6: `URI::RFC3986_PARSER.parse("https://example.test/a?x=1").freeze` is `frozen?` but **not** `Ractor.shareable?`, because `URI::Generic#freeze` is shallow and its `@host` and `@path` strings stay unfrozen; `Ractor.make_shareable(uri)` does return a shareable URI, and in the same call leaves `URI::RFC3986_PARSER.frozen?` true — it walks the object graph into the parser the URI references and deep-freezes that process-global object in place. Why it matters rather than being a curiosity: `Dexpace::Request` and `Dexpace::Response` hold a `URI::Generic`, so a phase that took the harvested sentence literally would either assert a shareability its models do not have, or reach for `make_shareable` and freeze a global as a side effect of building a request — which is exactly the in-place hazard `data-modeling/fa3eaf6e` and `/59efaae6` warn about, one level further out than they state it. What the SDK does instead: `Model.own` keeps making every collection member a deep-frozen copy, so `Headers`, `Query`, `MediaType`, `RequestOptions`, `Status`, `Method`, `Protocol` and `HeaderName` are all `Ractor.shareable?`; `Request` and `Response` are frozen with every collection member deep-frozen and are documented as not shareable, with the reason and the price of the alternative stated; and the tests assert the boundary in both directions rather than asserting the claim everywhere. `Ractor` is load-bearing nowhere in this port (`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`'s runtime floor keeps it out of the supported surface), so narrowing the claim costs nothing that was being relied on. - review · `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md` · high · sha:manual-phase1-ractor-uri -- **`Request` and `Response` are `Ractor.shareable?` as built, so the whole wire model is — correcting the phase-1 entry above (sha:manual-phase1-ractor-uri), whose mechanism was half right.** The entry rests on `data-modeling/996c0b12`, which it superseded on the strength of two facts: a frozen `URI::Generic` is not shareable because `URI#freeze` is shallow and `@host`/`@path` stay unfrozen, and `Ractor.make_shareable(uri)` would deep-freeze `URI::RFC3986_PARSER` in place. Verified on 2026-09-15 against 3.2.11 and 4.0.6 while building phase 1's `Dexpace::URL`: the first fact is true and is what `XCUT-15` needed answering anyway — a frozen URI's `host` is a mutable String a reader hands out, and `URI#dup` shares those Strings with the source, so `request.url.host << "x"` could change a model in place — but the second is moot, because `URI::RFC3986_PARSER.frozen?` and `Ractor.shareable?(URI::RFC3986_PARSER)` are already **true** on both interpreters: the uri gem freezes it at definition. What the SDK does: `Dexpace::URL.parse!` re-parses a URI input from its text (never `dup`, which aliases) and freezes each String-valued instance variable of the URI it now owns, skipping the parser reference; the URI and every model holding one are then `Ractor.shareable?` with no global touched — given a `nil` or frozen body, since the body member is opaque in phase 1 and carried as given — `request_test.rb` and `response_test.rb` assert it, and design §4's original claim stands in full. The phase-1 design's ledger row `P1-9` is retired as built and its replacement is `P1-13` (`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md`). `Ractor` is still load-bearing nowhere; the assertion exists so a later change to `URL.parse!` cannot lose the property silently. - review · `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md` · high · sha:manual-phase1-ractor-uri-as-built diff --git a/docs/sdk-documentation/http.md b/docs/sdk-documentation/http.md index 5b7ed70..f6bd2e2 100644 --- a/docs/sdk-documentation/http.md +++ b/docs/sdk-documentation/http.md @@ -16,9 +16,11 @@ same for `Status` as for `Request`: - **Frozen on construction.** A model never changes; a collection member is copied and deep-frozen once, at build (`Model.own`), so an accessor hands back the model's own frozen object with no per-call copy, and a `Hash` or `Array` the caller keeps a reference to cannot - reach the model afterwards. Every model — `Request` and `Response` included, when the opaque - body they carry is `nil` or frozen — is `Ractor.shareable?`, which is the one-line proof the - freeze reached every level and is not otherwise relied on. + reach the model afterwards, and a caller-supplied `String` member (`Response#reason`, a + `Method` token, a `HeaderName`) is copied and frozen the same way (`Model.frozen_string`). + Every model — `Request` and `Response` included, when the opaque body they carry is `nil` or + frozen — is `Ractor.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`, `#with` and a builder's `#build` all meet it, and a missing required member fails with the one message form ` is required` (`Dexpace::Model.required!`). @@ -60,6 +62,7 @@ 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 diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md index e403599..142464c 100644 --- a/docs/sdk-documentation/quality-gates.md +++ b/docs/sdk-documentation/quality-gates.md @@ -22,7 +22,7 @@ everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T | 9 | `gates:clean_bundle` | `SEAM-1`, `NFR-1`, `NFR-10`: each gem loads inside a scratch bundle holding only itself (and core, for an adapter); Bundler refuses every undeclared gem | a `require` of a bundled gem nobody declared — `logger` on Ruby 4.0 | | 10 | `gates:rbs_surface` | `NFR-11`: no constant outside `Dexpace::` and a stdlib allowlist in any public signature. The signatures are loaded as one set and every name resolved against it, the way `rbs validate` does, so a relative `Headers` inside `module Dexpace` and a type variable `T` are not leaks; every type tree is then walked, so a leak inside an optional, a union, a tuple, a record, a proc or a type argument is seen | `Async::Task` as a return type, superclass, mixin (name or type argument), type alias, class or module alias, class- or method-level generic bound, or anywhere inside a type; `DexpaceX::Thing`; a set that does not resolve | | 11 | `gates:sig_diff` | `NFR-4`: every public declaration in `sig/`, parsed and flattened to one line per overload with `private` honoured, against the last `v*` tag, per gem; a removed **or narrowed** declaration fails | a declaration or file gone since the tag, a dropped overload, a method moved under `private` (vacuous, by stated design, until the first tag) | -| 12 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers (those still public on the model) and singleton methods included, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act | +| 12 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers (those still public on the model) and singleton methods included, a `private_constant` excluded, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act | | 13 | `gates:single_instance` | design §2.4: one resolved path per core file; `Dexpace::VERSION` equals the gemspec. A second copy of core cannot finish loading once core holds `Data.define` models — Ruby refuses the re-opened class with `superclass mismatch` — and the gate reports that in the same terms, beside the features loaded twice before it | one core feature resolved from two directories; a version literal that disagrees | | 14 | `gates:versions` | `NFR-14`, `NFR-10`: `.ruby-version`, the CI matrix, six gemspec versions and floors and six `VERSION` literals all agree with `VERSIONS` | any consumer that drifted; a gemspec that does not load | | 15 | `gates:reproducible` | `NFR-12`: each gem built twice under a fixed `SOURCE_DATE_EPOCH` is byte-identical | a gemspec whose build depends on anything but its inputs | diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 264e16e..2db2834 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -2339,6 +2339,18 @@ design. phase 10's. **Code half: the one flag and its assertion.** Touches `NFR-7`, `NFR-17`. Added after phase 10's planning pass, so it is the thirty-fourth bullet and is not yet in its design's disposition table; phase 10 dispositions it at execution. +- **Nine later-phase design documents cite a retired corpus key, `data-modeling/5bc538ba`, as narrowing the + wire model's Ractor-shareability claim.** The phase 3, 4, 5 and 7 segmentation designs and the 4a, 4b, 5a, + 5b and 8a designs each lean on that note — "`data-modeling/5bc538ba` narrows the shareability claim" — and + on phase 1's ledger row `P1-9`. Phase 1's build reversed both on 2026-09-15: `Request` and `Response` are + `Ractor.shareable?` as built, `P1-9` is retired for `P1-13`, and the planning-time note is rewritten as built + under `## Conflicts` in `docs/knowledge/notes/data-modeling.md` (key digested from the new text; the old key + resolves to nothing, which is what `scripts/knowledge.rb --key` reports). Every one of those phases re-reads + the notes at its start, so none is misled at execution; what is stale is the prose of nine already-planned + documents, which is audit work against planned phases and therefore phase 10's. **Documentation half only: + repoint each citation and reword each sentence to the as-built claim.** Touches `HTTP-1`, `XCUT-15`. Added + after phase 10's planning pass, so it is the thirty-fifth bullet and is not yet in its design's disposition + table; phase 10 dispositions it at execution. **2026-09-13** — **Execution order amended by the roadmap-level generator-fitness review, which read the plan end to end against one question: will a generated OpenAPI client be able to use this?** No cell of @@ -2578,17 +2590,26 @@ invalid UTF-8 are rejected with the SDK's error rather than the regexp engine's. ledger row**: `Request` and `Response` *are* `Ractor.shareable?` as built, because `URL.parse!` had to freeze the URI's component Strings anyway (`URI#freeze` is shallow and `URI#dup` shares them, an `XCUT-15` alias the plan's `dup.freeze` left open) and `URI::RFC3986_PARSER` is already frozen by the uri gem on both -interpreters (given a `nil` or frozen body; the opaque body is carried as given) — `P1-9` is retired, `P1-13` records the ownership rule, and the corpus note that carried -`P1-9` gains a correcting entry. Two more ledger rows added at implementation: `P1-11`, `HeaderName`'s fold +interpreters (given a `nil` or frozen body; the opaque body is carried as given, and the reason phrase is copied +and frozen at build) — `P1-9` is retired, `P1-13` records the ownership rule, and the corpus note that carried +`P1-9` is rewritten as built, under `## Conflicts`, confirming `data-modeling/996c0b12` rather than superseding +it. Two more ledger rows added at implementation: `P1-11`, `HeaderName`'s fold is a derived attribute rather than a second member, and `P1-12`, `Query` equality compares encodings, which -is `HTTP-30` stated literally. Seventeen departures from the plan's text are itemised in the checklist, none +is `HTTP-30` stated literally. Nineteen departures from the plan's text are itemised in the checklist, none lowering a gate; five are gate or tool corrections the first real signatures forced, each pinned by a fixture on the tests branch: `gates:rbs_surface`'s stdlib list gains `Data`, `ArgumentError` and `StringScanner`; `rbs:validate` loads the allowlisted stdlib signature sets; `gates:single_instance` survives — and names — the `superclass mismatch` a second copy of a `Data.define` model raises; `tools/surface.rb` stops listing a reader the model made private; and `.rubocop.yml` admits Steep's `#: Type` annotation, which strict Steep -requires on an empty literal. The four notes the design filed stand; the phase-10 inbound list gains a -thirty-fourth bullet, the vacuous `rake rubocop` in a nested worktree. The three postponed items keep their -owners. The counts that changed: `gems/` is still six but `dexpace-core` is no longer a skeleton; the -phase-directory count is unchanged at eleven; `CLAUDE.md`'s "no domain code" paragraph, its construction -pattern and its gem sentence are rewritten from what was built. +requires on an empty literal. Three of the four notes the design filed stand and the fourth is rewritten; +the phase-10 inbound list gains two bullets, the thirty-fourth — the vacuous `rake rubocop` in a nested +worktree — and the thirty-fifth — nine later-phase design documents citing the retired note key +`data-modeling/5bc538ba`. The three postponed items keep their owners. The counts that changed: `gems/` is +still six but `dexpace-core` is no longer a skeleton; the phase-directory count is unchanged at eleven; +`CLAUDE.md`'s "no domain code" paragraph, its construction pattern and its gem sentence are rewritten from +what was built. **The round-1 review of the stack** found two +plan-level gaps the implementation had inherited — `Response` aliased the caller's `reason` String, and +`Headers.build` let a non-`Hash` reach the name walk as a `NoMethodError` — and one corpus mistake, the +planning-time note still marking `data-modeling/996c0b12` as superseded after the build had confirmed it; all +three are repaired on the branch they belong to (checklist deviations 15 and 18–19, the note above), with +`test:gems` at 256 runs and 100% line coverage on 4.0.6 and 3.2.11 afterwards. diff --git a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md index edd65db..d863870 100644 --- a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md +++ b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md @@ -21,7 +21,7 @@ Forty-two: `HTTP-1`–`HTTP-35`, `HTTP-46`–`HTTP-50`, `HTTP-53` and `SEAM-29`. | ID | Level | Status | Task(s) | What was built, and where it is proven | |---|---|---|---|---| -| `HTTP-1` | MUST | ✅ by construction | 2, 4–15 | Every model is `class X < Data.define(...)` including `Dexpace::Model`: frozen on construction, collections deep-frozen once through `Model.own`, `#with` the only derivation. `Ractor.shareable?` is asserted on every collection-holding model — `Headers`, `MediaType`, `Query`, `RequestOptions`, **and** `Request`/`Response` with a `nil` body (see `P1-9` as built, below; the opaque body is carried as given) — as the one-line proof the freeze reached every level (`dexpace/model_test.rb`, each model's suite) | +| `HTTP-1` | MUST | ✅ by construction | 2, 4–15 | Every model is `class X < Data.define(...)` including `Dexpace::Model`: frozen on construction, collections deep-frozen once through `Model.own`, `#with` the only derivation. `Ractor.shareable?` is asserted on every collection-holding model — `Headers`, `MediaType`, `Query`, `RequestOptions`, **and** `Request`/`Response` with a `nil` body (see `P1-9` as built, below; the opaque body is carried as given, and the reason phrase — the one caller-supplied `String` a model carries — is copied and frozen at build through `Model.frozen_string`, so it is never the caller's live object) — as the one-line proof the freeze reached every level (`dexpace/model_test.rb`, each model's suite) | | `HTTP-2` | MUST | ✅ by construction, gap stated | 2, 14 | `private_class_method :new` plus a validating `.build` on every model. The residual gap is asserted, not hidden: `dexpace/http/request_test.rb` proves `Request.send(:new, …)` reaches the constructor and that `Request.new` does not, and names the mitigation — wire-boundary re-validation, phase 8a Task 16 / 8c Task 9 / phase 9 Task 7 (design §10.10). Because validation lives in `initialize`, a forged instance is still a *valid* one; the gap is bypassing the builder's cross-field defaulting, not validation | | `HTTP-3` | MUST | ✅ / ⏳ / vacuous — split three ways | 5, 11, 13, 14, 15 | ✅ for the five models phase 1 builds: `Headers#new_builder` dups every value list and carries the direction, `Query#new_builder` dups every pair, `RequestOptions#new_builder` dups the tags, `Request#new_builder` and `Response#new_builder` carry frozen members; each suite derives, mutates the builder, builds a second instance and asserts the first unchanged. Value types with no builder derive through `Model#with`, which routes through `.build`. ⏳ for **the multipart body**: phase 1 ships no body, so `newBuilder()`-style derivation for it is `docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle.md` Task 7's. **`RequestConditions` is vacuous**: the type is `HTTP-50`'s and `HTTP-50` is ⏳ under `docs/first-release.md`, so there is no instance for the clause to bind to | | `HTTP-4` | MUST | ✅ | 1, 2, 5, 11, 13, 14, 15 | `Model.required!(name, value)` raises `Dexpace::InvalidArgumentError` with the one message form ` is required`; every model's `initialize` calls it for each required member — `Request` for method, url and headers, `Response` for request, protocol, status and headers in `HTTP-4`'s own order — so the field is named whether the model was reached through `.build`, `#with` or a builder (`dexpace/model_test.rb`; the "names a missing member" cases in `request_test.rb`, `response_test.rb`, `headers_test.rb`, `query_test.rb`, `request_options_test.rb`) | @@ -47,7 +47,7 @@ Forty-two: `HTTP-1`–`HTTP-35`, `HTTP-46`–`HTTP-50`, `HTTP-53` and `SEAM-29`. | `HTTP-24` | MUST | ✅ | 9 | `MediaType#charset` looks `charset` up under the folded key, folds the value, and returns it only when this Ruby's `Encoding.name_list` (minus the four process-relative pseudo-aliases) knows it; `nil` otherwise, never a raise; the `nil` is documented at the accessor (`media_type_test.rb`) | | `HTTP-25` | MUST | ✅ | 9 | A `StringScanner` parser over the byte-validated input: parameters split on `;` outside a quoted-string, each on its **first** `=`, quotes stripped and quoted-pairs unescaped; `#render` emits a value bare when it is a token and quoted-and-escaped otherwise. `parse(render(x)) == x` is a 64-sample property over an alphabet including `;`, `=`, `"` and `\` (`media_type_test.rb`) | | `HTTP-26` | MUST | ✅ | 3, 9 | `MediaType.parse` runs `HeaderSyntax.valid_outbound_value?` — the same predicate, not a copy — on the raw input before any character-oriented work, and `initialize` runs it on every parameter value; CR, DEL, non-ASCII and invalid UTF-8 are rejected with the SDK's error (`media_type_test.rb`) | -| `HTTP-27` | SHOULD | ✅ | 9 | `#matches?`: `*/*` matches everything, `type/*` any subtype of the type, parameters ignored; `*/json` is rejected at construction (`media_type_test.rb`) | +| `HTTP-27` | SHOULD | ✅ | 9 | `#matches?`: `*/*` matches everything, `type/*` any subtype of the type, parameters ignored; `*/json` is rejected at construction, and a non-`MediaType` operand is refused with the SDK's error rather than a `NoMethodError` (`media_type_test.rb`) | | `HTTP-28` | MUST | ✅ | 11 | `Query` is a list of `[name, value]` pairs: case-sensitive names, insertion order across names, multiple values per name, and `Query::Builder#add(name, nil)` stores `""` — one empty-string value, distinct from an absent name (`query_test.rb`, `query/builder_test.rb`) | | `HTTP-29` | MUST | ✅ | 10, 11 | `Query#encode` renders every name and value through `PercentEncoding.encode_component`, one occurrence per value, in insertion order, no leading `?`, `""` when empty (`query_test.rb`) | | `HTTP-30` | MUST | ✅ | 11 | `Query#==`/`#hash` compare the encodings, which is the requirement stated literally (`P1-12`); order-sensitive, and `parse(q.encode) == q` is a 64-sample property. An empty value list cannot reach the model: `Query::Builder#set(name, [])` removes the name (`query_test.rb`, `query/builder_test.rb`) | @@ -78,9 +78,9 @@ Module Layout section names, file for file. `require "uri"` and `require "strsca and both are on the require allowlist). The gemspec still has zero `add_dependency` lines. The gates, all seventeen, on **4.0.6** (`bundle exec rake`, 2026-09-15): green, exit 0 — `cops:test` 65 runs, -`steep` no type error over the strict `core` target, `test:gems` 252 runs / 1853 assertions with **100.00% line -coverage (768/768)** against the 80% floor, `test:gates` 128 runs / 523 assertions, the nine `gates:*` tasks, -`yard` 100.00% documented (15 modules, 16 classes, 60 constants, 8 attributes, 122 methods), `bundler_audit` +`steep` no type error over the strict `core` target, `test:gems` 256 runs / 1873 assertions with **100.00% line +coverage (775/775)** against the 80% floor, `test:gates` 128 runs / 523 assertions, the nine `gates:*` tasks, +`yard` 100.00% documented (15 modules, 16 classes, 50 constants, 8 attributes, 122 methods), `bundler_audit` clean. **One caveat about `rubocop`, stated in full under "Findings routed"**: run through `rake` from this worktree it inspected 8 files, because RuboCop inherits `AllCops/Exclude` from the topmost `.rubocop.yml` on the path and the parent checkout's excludes `.claude/**/*`, under which this worktree sits; run as @@ -95,10 +95,11 @@ the override removed (`Dexpace::Model.send(:remove_method, :with)` after load) ` the design's finding 1 reproduced against the built code. The surface manifest `test/fixtures/surface/dexpace-core.txt` grew from two lines to 221 through -`bundle exec rake surface:regenerate`, once, after the last task; the diff was read line by line and holds -exactly the constants and methods the twenty-two files define — `Headers::EMPTY_INBOUND` beside -`Headers::EMPTY`, `Method::IDEMPOTENT` (`P1-10`), and no private reader (deviation 10 below). `gates:sig_diff` -still prints "no release tag yet". +`bundle exec rake surface:regenerate`, once, after the last task, and to 211 after the round-1 review made +ten parser and codec constants `private_constant` (deviation 18); each diff was read line by line and the +manifest holds exactly the public constants and methods the twenty-two files define — +`Headers::EMPTY_INBOUND` beside `Headers::EMPTY`, `Method::IDEMPOTENT` (`P1-10`), and no private reader +(deviation 10 below). `gates:sig_diff` still prints "no release tag yet". ## Audit groups run @@ -113,12 +114,14 @@ contradicts this plan. `--req` was run for each task's IDs before that task. The | Minitest conventions | Clean. `testing/62f8f4ec`'s error-boundary pairing is applied to every builder — class, field-naming message, no partial side effect — and `testing/f36a19cd`'s bounded property tests exist for `HeaderName`'s fold, `Status`'s totality, `HeaderSyntax`'s grammar ordering, `PercentEncoding`, `MediaType` and `Query`. Three suites carry a nested test class per behaviour group (deviation 16), because `Style/OneClassPerFile` forbids a second top-level class and `Metrics/ClassLength` caps one at 100 lines | | Encoding and binary strings | Clean and load-bearing: every validator reads `value.b.each_byte`; `PercentEncoding` accumulates into a BINARY buffer and retags UTF-8 at the end; `Query.parse` and `HeaderSyntax.trim` work on `.b`; `HeaderSyntax.validate_name!` retags a *valid* name with the caller's encoding because its bytes are proven printable ASCII, which is `io-and-byte-streams/0a4773a6`'s "retag only where the bytes are known to conform" (deviation 4) | -The four notes the design filed are unchanged by implementation, with one correction filed as a fifth entry: -`docs/knowledge/notes/data-modeling.md` gains a `## Superseded` entry recording that `Request` and `Response` -**are** `Ractor.shareable?` as built, because `URL.parse!` freezes the URI's component strings it owns and the -uri gem already freezes `URI::RFC3986_PARSER` on 3.2.11 and 4.0.6 — the phase-1 entry's mechanism (shallow -`URI#freeze`, `make_shareable` freezing the global parser) was half right, and the half that was wrong is the -one the ledger row `P1-9` rested on. +Three of the four notes the design filed are unchanged by implementation. The fourth — the Ractor entry in +`docs/knowledge/notes/data-modeling.md` — is rewritten as built: it now stands under `## Conflicts`, records +that `Request` and `Response` **are** `Ractor.shareable?` because `URL.parse!` freezes the URI's component +strings it owns and the uri gem already freezes `URI::RFC3986_PARSER` on 3.2.11 and 4.0.6, and **confirms** +`data-modeling/996c0b12` (design §4's claim) rather than superseding it — the planning-time entry's mechanism +(shallow `URI#freeze`, `make_shareable` freezing the global parser) was half right, and the half that was +wrong is the one the ledger row `P1-9` rested on. The rewrite retires the planning-time entry's key, +`data-modeling/5bc538ba`; the later-phase design documents that cite it are routed below. ## Deviations from the plan @@ -177,7 +180,8 @@ change a gate or tool in the strict or the corrected direction, each with a fixt (probed on both interpreters), so the plan's form aliased externally-mutable state through `request.url.host` (`XCUT-15`). Consequence: **`Request` and `Response` are `Ractor.shareable?`** without any global touched, which retires the narrowing in `P1-9`; both suites assert shareability (with a `nil` body — the opaque - body is carried as given, so an unfrozen one is the caller's). Ledger row `P1-13`. + body is carried as given, so an unfrozen one is the caller's; the reason phrase is copied and frozen at + build, so it never is). Ledger row `P1-13`. 13. **The media-type parser is a `StringScanner` over per-pattern-timeout regexps**, not the plan's character loops, which tripped `Metrics/ClassLength`, `AbcSize` and the complexity cops. Every regexp runs only after `HeaderSyntax` proved the input printable ASCII, so none meets invalid UTF-8. It is lenient about a bare @@ -186,9 +190,13 @@ change a gate or tool in the strict or the corrected direction, each with a fixt two Strings with the same non-ASCII bytes under different encoding tags are not `String#==` yet encode to one wire query, and `HTTP-30` is stated in terms of the encoding. Ledger row `P1-12`. 15. **Eager type checks the plan did not have**: `Method.of` accepts `String` or `Symbol` and refuses anything - else; `HeaderName` requires a `String`; `Headers.build` requires a list of Strings per name; the builders' - writers refuse a wrong-typed value where they are set. Each pre-empts a `NoMethodError` that would otherwise - escape `rescue Dexpace::Error` — the Task 1 rule applied. + else; `HeaderName` requires a `String`; `Headers.build` requires a `Hash` for `values` and for `casing` + before anything reads them, and a list of Strings per name; `Response.build` requires a `String` (or + `nil`) reason and, per `XCUT-15`, stores a frozen copy of it through `Model.frozen_string` rather than the + caller's object — the plan's Task 15 code carried `reason: reason` straight to `super`, an alias the + round-1 review found; `MediaType#matches?` requires a `MediaType` operand; the builders' writers refuse a + wrong-typed value where they are set. Each pre-empts a `NoMethodError` that would otherwise escape + `rescue Dexpace::Error` — the Task 1 rule applied. 16. **Test layout**: every domain test file `require "dexpace"` itself (the shared helper cannot, or the smoke suite's namespace snapshot would be vacuous); `headers_test.rb`, `query_test.rb` and `response_test.rb` nest a second (and third) test class per behaviour group; the smoke suite snapshots the top level after @@ -197,6 +205,17 @@ change a gate or tool in the strict or the corrected direction, each with a fixt 17. **Minor defaults**: `Request.build(body: nil)` and `Response.build(reason: nil, body: nil)` default their optional members; `MediaType.build(parameters: {})` defaults; `Protocol.parse` and `MediaType.parse` require a `String`. +18. **Ten parser and codec constants are `private_constant`** — `MediaType::UNTIL_SEPARATOR`, `SEPARATOR`, + `KEY`, `QUOTED`, `QUOTED_PAIR` and `TO_ESCAPE`; `PercentEncoding::ENCODED`, `HEX_VALUES` and `PERCENT`; + `HeaderSyntax::TRIMMABLE` — so the manifest `NFR-4` locks at the first release tag carries none of them + (`Surface` walks `constants(false)`, which honours `private_constant`, so no gate changed). They stay + declared in `sig/`, where RBS has no visibility for a constant and Steep needs them to type the parser; each + `.rbs` says so beside them. A round-1 review nit, taken because it is cheapest before the tag. +19. **`Model#with` is typed `-> self` in RBS**, not the plan's `-> untyped`, so a consumer's own `steep check` + sees `Status::OK.with(code: 404)` as a `Status`. `-> instance`, the type the review suggested, does not + hold: Steep cannot prove `self <: instance` for a module whose self-type is the `_ModelInstance` + interface (deviation 5), and reports `Ruby::ReturnTypeMismatch` on `return self`; `self` is the receiver's + type by definition and is what the derivation returns. ## Findings routed @@ -209,6 +228,13 @@ change a gate or tool in the strict or the corrected direction, each with a fixt as-built note on `P1-9`, and as a corpus note; `docs/deviations.md` is phase 10's to flip and is untouched. - **`Dexpace::Protocol` has no alias for `http/1.0`** — already on phase 10's inbound list from phase 8a; phase 1 builds `HTTP-33` as stated and leaves the widening to that decision. +- **Nine later-phase design documents cite `data-modeling/5bc538ba` as narrowing the shareability claim** — + the phase 3, 4, 5 and 7 segmentation designs, 4a, 4b, 5a, 5b and 8a — and that key no longer resolves: the + planning-time note it named is withdrawn, as built, by the `## Conflicts` entry above, which says the + opposite (the whole wire model is shareable; the narrowing is retired with `P1-9`). None of those documents + is phase 1's to rewrite, and each of those phases re-reads the notes at its start (`--origin note --brief`, + which now surfaces the rewritten entry), so the repair is audit work against already-planned phases: routed + to phase 10's inbound list in `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md`. ## Postponed work diff --git a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md index a877153..f820a12 100644 --- a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md +++ b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md @@ -897,7 +897,7 @@ Each row is consolidated into design §10 and audited by `docs/deviations.md`. | P1-5 | `HTTP-17`'s "surrounding whitespace" is read as SP and HTAB only | `HTTP-17` | `String#strip` also strips NUL, so the obvious implementation accepts `"a\0"`. Trimming CR or LF would launder a header-splitting payload into a valid name | | P1-7 | `HTTP-5` is implemented in **two tiers** — a fresh per-call snapshot for the name set and the entry set, the model's own frozen list per name — where design §4 says "the same frozen reference is returned from every accessor" | `HTTP-5`; design §4, §10.11 | Appendix C's own text splits them: "Name-set and entry-set accessors return a fresh per-call snapshot; per-name value-list accessors return the instance's own list." Design §4's sentence is a summary of the second tier, the specification is normative, and both halves are asserted so neither can be collapsed later | | P1-8 | `Headers#==`/`#hash` compare folded names and values only; casing and direction take no part | `HTTP-13`; `api-design/e4fa3438` | `HTTP-13` puts equality and hashing under the fold. `Data` would generate equality over all three members, making `Accept` and `ACCEPT` unequal — the exact confusion the requirement exists to remove | -| P1-9 | `Request` and `Response` are frozen and deep-frozen in their collections but are **not** `Ractor.shareable?` — **retired as built, 2026-09-15; see P1-13** | design §4; `data-modeling/996c0b12` | The design's "deep-freezing at construction makes the whole wire model Ractor-shareable" holds for every model whose members are strings and collections, and not for the two that hold a `URI::Generic`: verified on 3.2.11 and 4.0.6, a frozen URI is not shareable because `@host` and `@path` stay unfrozen, and `Ractor.make_shareable(uri)` reaches shareability only by deep-freezing `URI::RFC3986_PARSER` — a process-global object — in place, which is the one thing §4 says never to do. `Ractor` is load-bearing nowhere (§4, §9), so the claim is narrowed rather than the global frozen. Filed as a corpus note. **As built:** the first half is right and the second was not needed — `URL.parse!` freezes the component strings it owns (P1-13), and `URI::RFC3986_PARSER` is already frozen by the uri gem on both interpreters, so both models are shareable with no global touched — given a `nil` or frozen body, which is opaque and carried as given in this phase — and design §4's original claim stands; both suites assert it, and the corpus note is corrected by a second entry | +| P1-9 | `Request` and `Response` are frozen and deep-frozen in their collections but are **not** `Ractor.shareable?` — **retired as built, 2026-09-15; see P1-13** | design §4; `data-modeling/996c0b12` | The design's "deep-freezing at construction makes the whole wire model Ractor-shareable" holds for every model whose members are strings and collections, and not for the two that hold a `URI::Generic`: verified on 3.2.11 and 4.0.6, a frozen URI is not shareable because `@host` and `@path` stay unfrozen, and `Ractor.make_shareable(uri)` reaches shareability only by deep-freezing `URI::RFC3986_PARSER` — a process-global object — in place, which is the one thing §4 says never to do. `Ractor` is load-bearing nowhere (§4, §9), so the claim is narrowed rather than the global frozen. Filed as a corpus note. **As built:** the first half is right and the second was not needed — `URL.parse!` freezes the component strings it owns (P1-13), and `URI::RFC3986_PARSER` is already frozen by the uri gem on both interpreters, so both models are shareable with no global touched — given a `nil` or frozen body, which is opaque and carried as given in this phase; the reason phrase, the one caller-supplied `String` a model carries, is copied and frozen at build — and design §4's original claim stands; both suites assert it, and the corpus note's planning-time entry is rewritten as built, under `## Conflicts`, confirming `data-modeling/996c0b12` rather than superseding it | | P1-10 | `Method::IDEMPOTENT` and `#idempotent?` are **public API** — a constant in `sig/` and a row in the `NFR-4` surface manifest — where `HTTP-9` says "the idempotent set is an internal constant rather than a public accessor on the method type" | `HTTP-9` | The requirement's substantive half — one set, the single source both the retry allow-list and the replay-safety gate derive from — is satisfied exactly, and nothing re-lists the five names. Only its shape parenthetical is departed from, because the two consumers live in sibling files and read it by receiver: phase 6a's `Resilience::Resend` calls `request.method.idempotent?` and phase 6b asserts against `Method::IDEMPOTENT` to prove it is *not* conflated with the redirect set. A private predicate cannot be called with an explicit receiver, so the alternative is a second copy of the set in `Resend` — the one thing `HTTP-9` exists to forbid. Recorded rather than reversed, because `NFR-4` locks the name at the first release tag | | P1-6 | `Request#==`/`#hash` override `Data`'s generated equality | `HTTP-46`; `api-design/e4fa3438` | `HTTP-46` requires comparison by textual external form; the generated equality would compare `URI::Generic` objects by `URI`'s own normalising rules, which is a different relation. The override carries the why-comment the styleguide requires | | P1-11 | `HeaderName` is `Data.define(:original)`; the fold is a derived attribute set before `super`, not a second member, and `.build(original:)` takes no `folded:` | this design's HeaderName section (`Data.define(:original, :folded)`); `HTTP-21` | Added at implementation, 2026-09-15. A derived value that is also a member has to be accepted and ignored — `#with(original: …)` carries the stale fold in `to_h` — which `Lint/UnusedMethodArgument` refuses and which is the argument `Status` already makes for keeping its name out of its members. Setting the ivar before `super` is the one moment a `Data` is mutable; on 3.2.11 the inherited `Data#with` leaves it `nil`, so `Model#with`'s route through `.build` is what makes the type sound on the floor, and the floor run proves it | From 692dbce421ab60d0389acc166f01c8807e29a328 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 10:20:50 +0300 Subject: [PATCH 3/4] docs: record the round-2 review repairs, name the String-only tag values as a reading (P1-14), and correct URL.own's comment --- docs/sdk-documentation/http.md | 24 ++++++--- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 14 ++++- ...phase1-core-http-domain-model-checklist.md | 54 +++++++++++++++---- ...05-phase1-core-http-domain-model-design.md | 1 + gems/dexpace-core/lib/dexpace/http/url.rb | 6 ++- 5 files changed, 77 insertions(+), 22 deletions(-) diff --git a/docs/sdk-documentation/http.md b/docs/sdk-documentation/http.md index f6bd2e2..b8f1ab3 100644 --- a/docs/sdk-documentation/http.md +++ b/docs/sdk-documentation/http.md @@ -14,10 +14,13 @@ private and a public, validating `.build`. That buys four properties at once, an same for `Status` as for `Request`: - **Frozen on construction.** A model never changes; a collection member is copied and - deep-frozen once, at build (`Model.own`), so an accessor hands back the model's own frozen - object with no per-call copy, and a `Hash` or `Array` the caller keeps a reference to cannot - reach the model afterwards, and a caller-supplied `String` member (`Response#reason`, a - `Method` token, a `HeaderName`) is copied and frozen the same way (`Model.frozen_string`). + deep-frozen once, at construction and only after its shape has been validated (`Model.own`, + called from each `initialize`), so an accessor hands back the model's own frozen object with + no per-call copy, a `Hash` or `Array` the caller keeps a reference to cannot reach the model + afterwards, and an object that cannot be copied — a `Proc` where a `Hash` belongs, a `Thread` + inside a value list — is refused as `InvalidArgumentError` rather than escaping as the + stdlib's `TypeError`; a caller-supplied `String` member (`Response#reason`, a `Method` token, + a `HeaderName`) is copied and frozen the same way (`Model.frozen_string`). Every model — `Request` and `Response` included, when the opaque body they carry is `nil` or frozen — is `Ractor.shareable?`, which is the one-line proof the freeze reached every level and is not otherwise relied on. @@ -74,6 +77,12 @@ method nor body is a `GET`; a body with no method reports `method is required` r 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`). + ## Headers, names and bytes `Dexpace::Headers` folds names for lookup, containment, equality and hashing and keeps the first @@ -118,9 +127,10 @@ casing, accepted everywhere a `String` name is. component encoder (`Dexpace::PercentEncoding`: space is `%20`, `+` is `%2B`, `~` is bare) and `Query.parse` is its lenient inverse. Two queries are equal exactly when they encode identically. -- **`RequestOptions`** — per-call `timeout` (a `Float` of seconds), `max_retries` and string - `tags`, every field `nil`/empty by default and `RequestOptions::EMPTY` the shared "override - nothing". These are operational knobs and deliberately not part of `Request`. +- **`RequestOptions`** — per-call `timeout` (a `Float` of seconds), `max_retries` and `tags`, a + `String`-to-`String` map (values too — a deliberate narrowing of `HTTP-34`'s "opaque", ledger + row `P1-14`), every field `nil`/empty by default and `RequestOptions::EMPTY` the shared + "override nothing". These are operational knobs and deliberately not part of `Request`. - **`URL`** — a module, not a type: `URL.parse!` pins `URI::RFC3986_PARSER`, refuses a relative URI, and returns a `URI::Generic` frozen through its components; `URL.external_form` is the textual key `Request` compares by, with no name resolution. diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 2db2834..2826c13 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -2595,7 +2595,7 @@ and frozen at build) — `P1-9` is retired, `P1-13` records the ownership rule, `P1-9` is rewritten as built, under `## Conflicts`, confirming `data-modeling/996c0b12` rather than superseding it. Two more ledger rows added at implementation: `P1-11`, `HeaderName`'s fold is a derived attribute rather than a second member, and `P1-12`, `Query` equality compares encodings, which -is `HTTP-30` stated literally. Nineteen departures from the plan's text are itemised in the checklist, none +is `HTTP-30` stated literally. Twenty-one departures from the plan's text are itemised in the checklist, none lowering a gate; five are gate or tool corrections the first real signatures forced, each pinned by a fixture on the tests branch: `gates:rbs_surface`'s stdlib list gains `Data`, `ArgumentError` and `StringScanner`; `rbs:validate` loads the allowlisted stdlib signature sets; `gates:single_instance` survives — and names — @@ -2612,4 +2612,14 @@ plan-level gaps the implementation had inherited — `Response` aliased the call `Headers.build` let a non-`Hash` reach the name walk as a `NoMethodError` — and one corpus mistake, the planning-time note still marking `data-modeling/996c0b12` as superseded after the build had confirmed it; all three are repaired on the branch they belong to (checklist deviations 15 and 18–19, the note above), with -`test:gems` at 256 runs and 100% line coverage on 4.0.6 and 3.2.11 afterwards. +`test:gems` at 256 runs and 100% line coverage on 4.0.6 and 3.2.11 afterwards. **The round-2 review** +found one more inherited gap and closed it the same way: a `Request` accepted a `Headers` the inbound +grammar had validated, so obs-text `HTTP-18` forbids could reach the model that represents an outbound +message through `.build`, `#with` or `headers=` followed by `#header` — `Request#initialize` and +`Request::Builder#headers=` now require the outbound direction (checklist deviation 20, the `HTTP-18` row). +Two nits were taken with it — `Model.own` moved from `.build` into each `initialize` after the shape +checks, so a non-copyable object is the SDK's error rather than `Ractor.make_shareable`'s `TypeError` +(deviation 21), and `Query.parse` refuses a non-`String` as its sibling factories do (deviation 17) — one +ledger row was added, `P1-14`, naming the `String`-only tag values of `RequestOptions` as a deliberate +reading of `HTTP-34`, and `URL.own`'s comment stopped asserting the retired `P1-9`. `test:gems` is at 260 +runs and 100% line coverage on 4.0.6 and 3.2.11 afterwards. diff --git a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md index d863870..a7be727 100644 --- a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md +++ b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md @@ -38,7 +38,7 @@ Forty-two: `HTTP-1`–`HTTP-35`, `HTTP-46`–`HTTP-50`, `HTTP-53` and `SEAM-29`. | `HTTP-15` | MUST | ✅ | 5 | `Headers::Builder#set(name, nil)` removes the header entirely — no value list, no casing entry (`headers/builder_test.rb`) | | `HTTP-16` | SHOULD | ✅ | 5 | Ruby's insertion-ordered `Hash`; `#names` and `#entries` come back in insertion order with original casing, and the first casing added is the one emitted (`headers_test.rb`, `headers/builder_test.rb`) | | `HTTP-17` | MUST | ✅ | 3, 4, 5 | `HeaderSyntax.trim` removes SP and HTAB only, on bytes (`P1-5`: `String#strip` strips NUL); `valid_name?` rejects empty, every byte below 0x21, DEL and every byte ≥ 0x80 by reading `name.b.each_byte`, so `"a\0"`, `"a\r"`, `"a\r\nb"`, `" "`, an interior space and invalid UTF-8 are all rejected with the SDK's error, never the regexp engine's; `" X-Trace "` is accepted as `X-Trace`. `HeaderName#initialize` and `Headers#initialize` run it again on every stored name, so `.build` and `#with` meet it too (`header_syntax_test.rb`, `header_name_test.rb`, `headers_test.rb`) | -| `HTTP-18` | MUST | ✅ | 3, 5 | `HeaderSyntax.valid_outbound_value?`: HTAB plus 0x20–0x7E, read as bytes; `"v\xC3\xA5lue"` rejected, `"a\tb"` accepted. `Headers::Builder` and `Headers#initialize` apply it for `:outbound`. A 128-sample property test proves the outbound grammar is strictly narrower than the inbound one (`header_syntax_test.rb`, `headers_test.rb`) | +| `HTTP-18` | MUST | ✅ | 3, 5 | `HeaderSyntax.valid_outbound_value?`: HTAB plus 0x20–0x7E, read as bytes; `"v\xC3\xA5lue"` rejected, `"a\tb"` accepted. `Headers::Builder` and `Headers#initialize` apply it for `:outbound`, and a `Request` carries **only** an outbound-validated collection: `Request#initialize` and `Request::Builder#headers=` (which the pre-filled constructor routes through) refuse a `Headers` whose `direction` is `:inbound`, whatever it holds, so an obs-text value cannot reach a request through `.build`, `#with` or `headers=` followed by `#header` — the last of which would otherwise derive a lenient builder from the inbound collection. `XCUT-18`'s "validated at the model layer BEFORE reaching any transport" therefore holds for the model that represents an outbound message, not only for the collection type (deviation 20; a round-2 review finding). A 128-sample property test proves the outbound grammar is strictly narrower than the inbound one (`header_syntax_test.rb`, `headers_test.rb`, `request_test.rb`, `request/builder_test.rb`) | | `HTTP-19` | MUST | ✅ | 3, 5, 15 | `HeaderSyntax.valid_inbound_value?` admits obs-text and refuses C0-except-HTAB and DEL; inbound names go through `validate_name!` unchanged. `direction` is a **member** of `Headers`, so `#new_builder` on an inbound model is inbound and `Headers::EMPTY_INBOUND` is `Response::Builder`'s default — asserted both ways, an inbound model deriving a lenient builder and an outbound one still refusing obs-text (`headers_test.rb`, `response_test.rb`) | | `HTTP-20` | MUST | ✅ | 3, 5 | A rejected value never appears in a message in any form (`"secret\r\ntoken"` under `Authorization`: the message names the header and not `secret`); a rejected name is echoed with its control bytes escaped as `\xNN` through `HeaderSyntax.escape`, which itself reads bytes (`header_syntax_test.rb`, `headers/builder_test.rb`) | | `HTTP-21` | MUST | ✅ | 4, 5 | `HeaderName` compares and hashes by `#folded`, keeps `#original` for emission, interoperates with the string-keyed API (`HeaderName.of` accepts a `String` or a `HeaderName`; every `Headers` method accepts either), and enforces `HTTP-17`. The fold is a derived attribute set before `super`, not a second member (`P1-11`) (`header_name_test.rb`) | @@ -54,7 +54,7 @@ Forty-two: `HTTP-1`–`HTTP-35`, `HTTP-46`–`HTTP-50`, `HTTP-53` and `SEAM-29`. | `HTTP-31` | MUST | ✅ | 10, 11 | `Query.parse`: `nil` or blank → `Query::EMPTY`, a leading `?` tolerated, a segment with no `=` or a trailing `=` → `""`, a stray `&` skipped, a malformed escape kept raw; `PercentEncoding.decode_component` is lenient and total. Both read bytes, so a query carrying invalid UTF-8 parses and re-encodes byte-exactly (`query_test.rb`, `percent_encoding_test.rb`) | | `HTTP-32` | SHOULD | ✅ | 10 | `PercentEncoding.encode_component`: unreserved set exactly `A-Za-z0-9-._~`, everything else uppercase-escaped — `a b*~+/!()'` → `a%20b%2A~%2B%2F%21%28%29%27` byte for byte; `decode_component("a+b")` is `"a+b"`; `%FF` round-trips (`percent_encoding_test.rb`) | | `HTTP-33` | MUST | ✅ | 8 | `Protocol` over the canonical forms `http/1.1` and `http/2`; `Protocol.parse` accepts those and the aliases `HTTP/2` and `HTTP/2.0` case-insensitively (`downcase`, no argument, after a byte check) and raises naming an unrecognised identifier; `.build` accepts only a canonical form, so `#with` cannot admit an alias (`protocol_test.rb`) | -| `HTTP-34` | MUST | ✅ | 13 | `RequestOptions` — `timeout` (a `Float` of seconds), `max_retries`, `tags` — every field `nil`/empty by default, `RequestOptions::EMPTY` one shared frozen instance, `tags` `Model.own`ed at build and a map of `String` to `String` (`request_options_test.rb`) | +| `HTTP-34` | MUST | ✅ | 13 | `RequestOptions` — `timeout` (a `Float` of seconds), `max_retries`, `tags` — every field `nil`/empty by default, `RequestOptions::EMPTY` one shared frozen instance, `tags` `Model.own`ed at construction and a map of `String` to `String`. **Tag values are `String` only, and that is a reading, not an oversight**: `HTTP-34` says "opaque string-keyed tags" and `HTTP-1`'s carve-out leaves a tag value's own mutability to the caller, so an arbitrary object would be admissible; the design fixed `String → String` so `Model.own` can deep-copy and deep-freeze the map without meeting an object it cannot copy (a `Thread`, a `Proc` — the failure deviation 21 removes for collections generally), and the conservative direction is the safe one to widen later. Ledger row `P1-14`; a phase that needs a non-`String` tag value reopens it there (`request_options_test.rb`) | | `HTTP-35` | MUST | ✅ | 13 | In the model's `initialize`, so `.build` and `#with` meet it: a non-nil timeout that is zero or negative raises, a negative `max_retries` raises, `0` is accepted and means "disable retries for this call" (`request_options_test.rb`) | | `HTTP-46` | MUST | ✅ (URL and method/headers), ⏳ (body by value) | 12, 14 | `Request#==`/`#hash` compare `URL.external_form(url)` — a textual key; Ruby's `URI` resolves nothing, and the test compares two textually different URLs naming one host without touching the network — plus method, headers and body by `==` (`P1-6`). The body half is opaque here and was built by phase 3b (`P3-15`, `docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle.md`) (`url_test.rb`, `request_test.rb`) | | `HTTP-47` | SHOULD | ✅ | 12, 14 | `URL.parse!` pins `::URI::RFC3986_PARSER`, refuses a non-absolute URI naming it, and converts `URI::InvalidURIError` into a `Dexpace::InvalidArgumentError` carrying the offending input with the original as `cause`; `Request::Builder#build` and `Request#initialize` both route the URL through it, so `"::bad"` is named from either (`url_test.rb`, `request/builder_test.rb`) | @@ -78,8 +78,8 @@ Module Layout section names, file for file. `require "uri"` and `require "strsca and both are on the require allowlist). The gemspec still has zero `add_dependency` lines. The gates, all seventeen, on **4.0.6** (`bundle exec rake`, 2026-09-15): green, exit 0 — `cops:test` 65 runs, -`steep` no type error over the strict `core` target, `test:gems` 256 runs / 1873 assertions with **100.00% line -coverage (775/775)** against the 80% floor, `test:gates` 128 runs / 523 assertions, the nine `gates:*` tasks, +`steep` no type error over the strict `core` target, `test:gems` 260 runs / 1897 assertions with **100.00% line +coverage (780/780)** against the 80% floor, `test:gates` 128 runs / 523 assertions, the nine `gates:*` tasks, `yard` 100.00% documented (15 modules, 16 classes, 50 constants, 8 attributes, 122 methods), `bundler_audit` clean. **One caveat about `rubocop`, stated in full under "Findings routed"**: run through `rake` from this worktree it inspected 8 files, because RuboCop inherits `AllCops/Exclude` from the topmost `.rubocop.yml` on @@ -126,7 +126,8 @@ wrong is the one the ledger row `P1-9` rested on. The rewrite retires the planni ## Deviations from the plan Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate; three -change a gate or tool in the strict or the corrected direction, each with a fixture. +change a gate or tool in the strict or the corrected direction, each with a fixture. Items 20 and 21 +are the round-2 review's repairs, landed on 2026-09-15 with the test counts above re-derived afterwards. 1. **`Dexpace::Model#with(changes = nil)` takes a positional `Hash`, not `**changes`.** `Dexpace/NoKeywordSplat` flags every public `def` in `gems/*/lib` with a `**` parameter, and an inline disable would special-case a @@ -195,16 +196,21 @@ change a gate or tool in the strict or the corrected direction, each with a fixt `nil`) reason and, per `XCUT-15`, stores a frozen copy of it through `Model.frozen_string` rather than the caller's object — the plan's Task 15 code carried `reason: reason` straight to `super`, an alias the round-1 review found; `MediaType#matches?` requires a `MediaType` operand; the builders' writers refuse a - wrong-typed value where they are set. Each pre-empts a `NoMethodError` that would otherwise escape - `rescue Dexpace::Error` — the Task 1 rule applied. + wrong-typed value where they are set; and `Request#initialize`, `Request::Builder#headers=` and the + builder's pre-filled constructor require the `Headers` to be outbound-validated (deviation 20). Each + pre-empts a `NoMethodError` that would otherwise escape `rescue Dexpace::Error` — the Task 1 rule + applied — except the last, which closes a grammar gap rather than an error-class one. 16. **Test layout**: every domain test file `require "dexpace"` itself (the shared helper cannot, or the smoke - suite's namespace snapshot would be vacuous); `headers_test.rb`, `query_test.rb` and `response_test.rb` nest a - second (and third) test class per behaviour group; the smoke suite snapshots the top level after + suite's namespace snapshot would be vacuous); `headers_test.rb`, `query_test.rb`, `request_test.rb` and `response_test.rb` + nest a second (and third) test class per behaviour group; the smoke suite snapshots the top level after `require "uri"` and `require "strscan"`, whose constants are theirs. Run counts exceed the plan's expected numbers throughout. 17. **Minor defaults**: `Request.build(body: nil)` and `Response.build(reason: nil, body: nil)` default their - optional members; `MediaType.build(parameters: {})` defaults; `Protocol.parse` and `MediaType.parse` require a - `String`. + optional members; `MediaType.build(parameters: {})` defaults; `Protocol.parse`, `MediaType.parse` and — since the + round-2 review — `Query.parse` require a `String` (`Query.parse` still maps `nil` to `Query::EMPTY`, as + `HTTP-31` asks), so a non-`String` is the SDK's error from every parse factory and never a + `NoMethodError` from `.b`. `Query.parse`'s blank-input branch went with it: a blank query has no + segment left after the strip and the split, so it reaches `EMPTY` through the same path as `"?"`. 18. **Ten parser and codec constants are `private_constant`** — `MediaType::UNTIL_SEPARATOR`, `SEPARATOR`, `KEY`, `QUOTED`, `QUOTED_PAIR` and `TO_ESCAPE`; `PercentEncoding::ENCODED`, `HEX_VALUES` and `PERCENT`; `HeaderSyntax::TRIMMABLE` — so the manifest `NFR-4` locks at the first release tag carries none of them @@ -216,6 +222,32 @@ change a gate or tool in the strict or the corrected direction, each with a fixt hold: Steep cannot prove `self <: instance` for a module whose self-type is the `_ModelInstance` interface (deviation 5), and reports `Ruby::ReturnTypeMismatch` on `return self`; `self` is the receiver's type by definition and is what the derivation returns. +20. **A `Request` carries only an outbound-validated `Headers`.** The plan's Task 14 checked + `headers.is_a?(Headers)` and nothing more, and the design's Request section is silent on direction, + so `Request.build(headers: Headers.inbound_builder.add("X-Trace", "v\xC3\xA5lue").build)` succeeded + and `builder.headers = inbound; builder.header("X-Other", "\xE9")` accepted a byte + `Request.builder.header` alone rejects, because `#header` derives its builder from the collection it + was handed. `Request#initialize` and `Request::Builder#headers=` now require `direction == :outbound` + — the direction, not the content, because the direction is what every later derivation inherits — and + the builder's pre-filled constructor routes its `headers:` through the writer. Not a ledger row: it is + `HTTP-18` and `XCUT-18` stated for the model that represents an outbound message, an inherited gap + the round-2 review found rather than a departure from either. Control bytes were refused by both + grammars throughout, so the gap was obs-text, never request splitting; phase 8a's dispatch re-check + would have stopped it at the wire, but `HTTP-18` is this phase's row. +21. **`Model.own` runs in each model's `initialize`, after the shape checks, not in `.build` before + `new`.** The plan's Tasks 5 and 11 wrote `Model.own` inside `.build`, and the build gave all four + collection-holding models that shape (Tasks 5, 9, 11 and 13) — ahead of the container check + `initialize` carries, so `Headers.build(values: -> {}, casing: {})`, + `Query.build(pairs: -> {})`, `MediaType.build(parameters: -> {})` and + `RequestOptions.build(tags: -> {})` — and a `Thread` nested inside a value list or a tag map — escaped as + the stdlib's `TypeError: allocator undefined for Proc` from `Ractor.make_shareable(copy: true)` before + any check ran (a round-2 review nit). Moving the copy to the `super` call, after `initialize` has + validated every container and every element, means `make_shareable` only ever meets a `Hash`, an `Array` + and `String`s, which it always copies; nothing is copied before it is validated, so an `IO` handed where + a `Hash` belongs is refused before `dup` would have opened a second descriptor. The design's rule — + collections duplicated and frozen exactly once, at construction — is unchanged; only the line within + construction moved. The forged `send(:new, …)` path now owns its collections too, which narrows `HTTP-2`'s + admitted gap by one aliasing hazard without claiming to close it. ## Findings routed diff --git a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md index f820a12..a5abce9 100644 --- a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md +++ b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md @@ -903,6 +903,7 @@ Each row is consolidated into design §10 and audited by `docs/deviations.md`. | P1-11 | `HeaderName` is `Data.define(:original)`; the fold is a derived attribute set before `super`, not a second member, and `.build(original:)` takes no `folded:` | this design's HeaderName section (`Data.define(:original, :folded)`); `HTTP-21` | Added at implementation, 2026-09-15. A derived value that is also a member has to be accepted and ignored — `#with(original: …)` carries the stale fold in `to_h` — which `Lint/UnusedMethodArgument` refuses and which is the argument `Status` already makes for keeping its name out of its members. Setting the ivar before `super` is the one moment a `Data` is mutable; on 3.2.11 the inherited `Data#with` leaves it `nil`, so `Model#with`'s route through `.build` is what makes the type sound on the floor, and the floor run proves it | | P1-12 | `Query#==`/`#hash` compare the encodings, overriding `Data`'s generated equality over the pair list | `HTTP-30`; `api-design/e4fa3438` | Added at implementation, 2026-09-15. Two Strings holding the same non-ASCII bytes under different encoding tags are not `String#==` yet encode to one wire query; `HTTP-30` states equality as "equal iff they encode identically", so comparing the encodings is the requirement literally. The override carries the why-comment the styleguide requires | | P1-13 | `URL.parse!` re-parses a URI input from its text and freezes the URI's component Strings, rather than `dup.freeze` | this plan's Task 12; `XCUT-15`; design §4 | Added at implementation, 2026-09-15. `URI::Generic#freeze` is shallow and `URI#dup` shares `@host`, `@path` and `@query` with the source (probed on 3.2.11 and 4.0.6), so the plan's `input.dup.freeze` left `request.url.host << "x"` able to change a model in place through a reader, and a caller's later mutation of their own URI able to reach it. Parsing from the text yields components the method owns, which it freezes without touching the caller's object or the process-global parser. The consequence retires P1-9 | +| P1-14 | `RequestOptions` tag **values** are `String` only — `tags` is a `String → String` map, and a non-`String` value is refused at construction | `HTTP-34` ("opaque string-keyed tags"); `HTTP-1` ("per-call tag values are opaque so their own mutability is the caller's concern") | Added at implementation, recorded 2026-09-15 after the round-2 review. The spec fixes the key type and leaves the value opaque, so an arbitrary object would be admissible and its mutability the caller's problem; this design's RequestOptions section chose `String → String` from the start so `Model.own` can deep-copy and deep-freeze the map at construction without meeting an object it cannot copy — a `Thread` or a `Proc` as a tag value is exactly the `TypeError` the round-2 review's collection finding removed elsewhere. Narrowing is the safe direction: a later phase that needs a non-`String` tag value (an object, a duration) widens this row and the `validate_tags!` check together, and nothing built on the narrow reading breaks when it does. Neither the ledger nor the `HTTP-34` checklist row named this as a reading before; both do now | ## Work Phase 1 Postponed, and Who Owns It Now diff --git a/gems/dexpace-core/lib/dexpace/http/url.rb b/gems/dexpace-core/lib/dexpace/http/url.rb index 7ee7916..0347161 100644 --- a/gems/dexpace-core/lib/dexpace/http/url.rb +++ b/gems/dexpace-core/lib/dexpace/http/url.rb @@ -57,8 +57,10 @@ def external_form(uri) private # Freezes the URI's String components, then the URI. Only Strings: a URI also references - # its parser, a process-global object that must never be frozen from here (deviation P1-9), - # and that reference is why the result is frozen without being Ractor-shareable. + # its parser, a process-global object this method leaves alone -- the uri gem freezes + # URI::RFC3986_PARSER at definition on every supported Ruby, so nothing here needs to touch + # it -- which is why the result is frozen AND Ractor-shareable with no global frozen from + # here (deviation P1-13; the request and response suites assert it). def own(uri) uri.instance_variables.each do |name| value = uri.instance_variable_get(name) From cb45703fa9af1715e1c2c85a664f95a652c3c85e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 11:15:42 +0300 Subject: [PATCH 4/4] docs: record the round-3 review repairs (deviation 22), recount the checklist roll-up, cite HTTP-19's name tests and route URL.parse!'s host-less shapes --- docs/sdk-documentation/http.md | 6 +- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 36 ++++++++- ...phase1-core-http-domain-model-checklist.md | 79 ++++++++++++++----- 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/docs/sdk-documentation/http.md b/docs/sdk-documentation/http.md index b8f1ab3..3b7a1a5 100644 --- a/docs/sdk-documentation/http.md +++ b/docs/sdk-documentation/http.md @@ -98,9 +98,13 @@ validated them, are equal. Every name and every value is validated **as bytes** | 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. `Headers.builder` validates by the outbound grammar and `Headers.inbound_builder` +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. diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 2826c13..b9d396a 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -2351,6 +2351,23 @@ design. repoint each citation and reword each sentence to the as-built claim.** Touches `HTTP-1`, `XCUT-15`. Added after phase 10's planning pass, so it is the thirty-fifth bullet and is not yet in its design's disposition table; phase 10 dispositions it at execution. +- **`Dexpace::URL.parse!` accepts a host-less `http:` and any absolute non-HTTP URI, so a `Request` can carry a + URL no transport can dispatch, and nothing owns the rejection.** Found by the round-3 review of phase 1's + stack on 2026-09-15: `URI::Generic#absolute?` is only "a scheme is present", so `URL.parse!("http:")` + yields a `URI::HTTP` with a `nil` host and `URL.parse!("mailto:x@y")` a `URI::MailTo`, while `"/rel"` and + `"example.test/a"` are correctly refused. `HTTP-47`'s letter — malformed or non-absolute — is met; its + rationale, failing at construction "rather than surfacing a lower-level or transport-specific error later", + is not, for those two shapes, which today surface as whatever `Net::HTTP.new(nil, …)` raises inside phase + 8a's `Adapter`. Phase 1 changed nothing: which schemes and shapes are dispatchable is a transport's + knowledge, not the wire model's, and the two candidate owners are both already planned — phase 8a's + `RequestMapper` and `Adapter` (Tasks 16 and 19, + `docs/work/mvp/phase8/phase8a/2026-09-11-phase8a-synchronous-transport-and-conformance.md`), where a + `TransportError` naming the URL would be the natural shape, or `URL.parse!` itself if a + transport-independent "http or https with a host" rule is preferred and recorded against `HTTP-47`. + **Code half: one check in one of those two places, with its negative tests; documentation half: the + choice, in the owning phase's checklist row.** Touches `HTTP-47`. Added after phase 10's + planning pass, so it is the thirty-sixth bullet and is not yet in its design's disposition table; phase 10 + dispositions it at execution. **2026-09-13** — **Execution order amended by the roadmap-level generator-fitness review, which read the plan end to end against one question: will a generated OpenAPI client be able to use this?** No cell of @@ -2580,9 +2597,9 @@ rewritten from what was built. documentation. `dexpace-core` now carries the HTTP domain model — twenty-two new `lib/` files under `lib/dexpace/`, each with its `sig/` and `test/` mirror, exactly the layout the design's Module Layout section names — and the other five gems are still phase-0 skeletons at `0.0.0`. The checklist is at -`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md`: forty-two rows, 36 ✅ (`HTTP-3` -split three ways, `HTTP-46`'s body half deferred to 3b), 4 ⏳ (`HTTP-22`, `HTTP-48`–`HTTP-50`, all under -`docs/first-release.md`), none 🚫. `bundle exec rake` is green on 4.0.6 with 100% line coverage against the +`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md`: forty-two rows, 38 ✅ (two by +construction — `HTTP-1`, `HTTP-2` — and two split with a deferred half — `HTTP-3` three ways, `HTTP-46`'s body +half deferred to 3b), 4 ⏳ (`HTTP-22`, `HTTP-48`–`HTTP-50`, all under `docs/first-release.md`), none 🚫. `bundle exec rake` is green on 4.0.6 with 100% line coverage against the 80% floor; the matrix set is green on 3.2.11 and `test:gems` on 3.3.12 and 3.4.10 as well. **The two interpreter findings the design was built on held**: `Model#with` is proven load-bearing by removing it and watching the floor fail while 4.0.6 passes, and every validator reads bytes, so `"a\0"`, `"a\r\nb"` and @@ -2622,4 +2639,15 @@ checks, so a non-copyable object is the SDK's error rather than `Ractor.make_sha (deviation 21), and `Query.parse` refuses a non-`String` as its sibling factories do (deviation 17) — one ledger row was added, `P1-14`, naming the `String`-only tag values of `RequestOptions` as a deliberate reading of `HTTP-34`, and `URL.own`'s comment stopped asserting the retired `P1-9`. `test:gems` is at 260 -runs and 100% line coverage on 4.0.6 and 3.2.11 afterwards. +runs and 100% line coverage on 4.0.6 and 3.2.11 afterwards. **The round-3 review** found the mirror image of +the design's finding 3: every validator reads bytes, so a String whose bytes are ASCII under a tag Ruby cannot +fold under — `"Accept".encode("ISO-2022-JP")`, the same bytes as the literal — passed every check and then +crashed the fold, the upcase or the scanner with `Encoding::CompatibilityError`, escaping `rescue +Dexpace::Error` from eight public entry points. `HeaderSyntax.ascii_compatible` is the one normalisation, +applied before every fold, upcase and scan in core (checklist deviation 22; one new public method, the +manifest regenerated deliberately); `HTTP-19`'s third clause — inbound names stay strict — gained the tests +the code already satisfied; the checklist's own roll-up sentence and this note's copy of it were recounted from +the table (38 ✅, not 36); two nits were taken — a non-finite or non-real timeout and a non-`Hash` argument to +`#with` are the SDK's error — and one was routed, `URL.parse!`'s host-less and non-HTTP shapes, as the +thirty-sixth phase-10 inbound bullet above. `test:gems` is at 273 runs and 100% line coverage on 4.0.6 and +3.2.11 afterwards. diff --git a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md index a7be727..8fda1c6 100644 --- a/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md +++ b/docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md @@ -33,29 +33,29 @@ Forty-two: `HTTP-1`–`HTTP-35`, `HTTP-46`–`HTTP-50`, `HTTP-53` and `SEAM-29`. | `HTTP-10` | MUST | ✅ | 6 | `Status.of(code)` is total over 100–599 — a 256-sample property test — and never raises for an unrecognised code; `Status.canonical_name(code)` and `#canonical_name` are the separate lookup and answer `nil` for a vendor code (499, 520, 526, 530, 599 asserted). A non-Integer or an out-of-range Integer is a caller mistake and raises (`status_test.rb`) | | `HTTP-11` | MUST | ✅ | 6, 15 | Six range predicates on `Status`, derived from the code; `Response` exposes the same six as one-line delegations (`status_test.rb`, `response_test.rb`) | | `HTTP-12` | MUST | ✅ | 6 | `Status` has one member, `code`; equality and hash are `Data`'s over it, so `Status.of(200) == Status::OK` and both hash identically, with the name a lookup rather than a member (`status_test.rb`) | -| `HTTP-13` | MUST | ✅ | 3, 4, 5 | `HeaderName#folded` is `downcase` with no argument (`Dexpace/NoLocaleCaseFold` rejects the argument form repository-wide); `Headers` keys `values` and `casing` by the fold for storage, lookup, containment, mutation, removal, equality and hashing; `Headers#==`/`#hash` are over the folded values only (`P1-8`). The fold never meets a non-ASCII byte because `HTTP-17` rejected it first (`header_name_test.rb`, `headers_test.rb`) | +| `HTTP-13` | MUST | ✅ | 3, 4, 5 | `HeaderName#folded` is `downcase` with no argument (`Dexpace/NoLocaleCaseFold` rejects the argument form repository-wide); `Headers` keys `values` and `casing` by the fold for storage, lookup, containment, mutation, removal, equality and hashing; `Headers#==`/`#hash` are over the folded values only (`P1-8`). The fold never meets a non-ASCII byte because `HTTP-17` rejected it first, nor a tag it cannot fold under: a name whose bytes are ASCII under a stateful or wide encoding (ISO-2022-JP, UTF-16) is retagged before the fold rather than crashing it (deviation 22) (`header_name_test.rb`, `headers_test.rb`) | | `HTTP-14` | MUST | ✅ | 5 | `Headers::Builder#add` appends, `#set` replaces the whole list; per-name insertion order is the array's (`headers/builder_test.rb`) | | `HTTP-15` | MUST | ✅ | 5 | `Headers::Builder#set(name, nil)` removes the header entirely — no value list, no casing entry (`headers/builder_test.rb`) | | `HTTP-16` | SHOULD | ✅ | 5 | Ruby's insertion-ordered `Hash`; `#names` and `#entries` come back in insertion order with original casing, and the first casing added is the one emitted (`headers_test.rb`, `headers/builder_test.rb`) | -| `HTTP-17` | MUST | ✅ | 3, 4, 5 | `HeaderSyntax.trim` removes SP and HTAB only, on bytes (`P1-5`: `String#strip` strips NUL); `valid_name?` rejects empty, every byte below 0x21, DEL and every byte ≥ 0x80 by reading `name.b.each_byte`, so `"a\0"`, `"a\r"`, `"a\r\nb"`, `" "`, an interior space and invalid UTF-8 are all rejected with the SDK's error, never the regexp engine's; `" X-Trace "` is accepted as `X-Trace`. `HeaderName#initialize` and `Headers#initialize` run it again on every stored name, so `.build` and `#with` meet it too (`header_syntax_test.rb`, `header_name_test.rb`, `headers_test.rb`) | +| `HTTP-17` | MUST | ✅ | 3, 4, 5 | `HeaderSyntax.trim` removes SP and HTAB only, on bytes (`P1-5`: `String#strip` strips NUL); `valid_name?` rejects empty, every byte below 0x21, DEL and every byte ≥ 0x80 by reading `name.b.each_byte`, so `"a\0"`, `"a\r"`, `"a\r\nb"`, `" "`, an interior space and invalid UTF-8 are all rejected with the SDK's error, never the regexp engine's; `" X-Trace "` is accepted as `X-Trace`, under the caller's encoding when it can carry ASCII and under US-ASCII when it cannot (deviation 22). `HeaderName#initialize` and `Headers#initialize` run it again on every stored name, so `.build` and `#with` meet it too (`header_syntax_test.rb`, `header_name_test.rb`, `headers_test.rb`) | | `HTTP-18` | MUST | ✅ | 3, 5 | `HeaderSyntax.valid_outbound_value?`: HTAB plus 0x20–0x7E, read as bytes; `"v\xC3\xA5lue"` rejected, `"a\tb"` accepted. `Headers::Builder` and `Headers#initialize` apply it for `:outbound`, and a `Request` carries **only** an outbound-validated collection: `Request#initialize` and `Request::Builder#headers=` (which the pre-filled constructor routes through) refuse a `Headers` whose `direction` is `:inbound`, whatever it holds, so an obs-text value cannot reach a request through `.build`, `#with` or `headers=` followed by `#header` — the last of which would otherwise derive a lenient builder from the inbound collection. `XCUT-18`'s "validated at the model layer BEFORE reaching any transport" therefore holds for the model that represents an outbound message, not only for the collection type (deviation 20; a round-2 review finding). A 128-sample property test proves the outbound grammar is strictly narrower than the inbound one (`header_syntax_test.rb`, `headers_test.rb`, `request_test.rb`, `request/builder_test.rb`) | -| `HTTP-19` | MUST | ✅ | 3, 5, 15 | `HeaderSyntax.valid_inbound_value?` admits obs-text and refuses C0-except-HTAB and DEL; inbound names go through `validate_name!` unchanged. `direction` is a **member** of `Headers`, so `#new_builder` on an inbound model is inbound and `Headers::EMPTY_INBOUND` is `Response::Builder`'s default — asserted both ways, an inbound model deriving a lenient builder and an outbound one still refusing obs-text (`headers_test.rb`, `response_test.rb`) | +| `HTTP-19` | MUST | ✅ | 3, 5, 15 | `HeaderSyntax.valid_inbound_value?` admits obs-text and refuses C0-except-HTAB and DEL; inbound names go through `validate_name!` unchanged. `direction` is a **member** of `Headers`, so `#new_builder` on an inbound model is inbound and `Headers::EMPTY_INBOUND` is `Response::Builder`'s default — asserted both ways, an inbound model deriving a lenient builder and an outbound one still refusing obs-text. The third clause — names stay strict — is asserted on its own since the round-3 review: `Headers.inbound_builder.add` refuses a non-ASCII, CRLF, NUL, spaced or blank name, and an inbound `Headers.build` refuses a non-ASCII or CRLF stored key (`headers_test.rb`, `headers/builder_test.rb`, `response_test.rb`) | | `HTTP-20` | MUST | ✅ | 3, 5 | A rejected value never appears in a message in any form (`"secret\r\ntoken"` under `Authorization`: the message names the header and not `secret`); a rejected name is echoed with its control bytes escaped as `\xNN` through `HeaderSyntax.escape`, which itself reads bytes (`header_syntax_test.rb`, `headers/builder_test.rb`) | | `HTTP-21` | MUST | ✅ | 4, 5 | `HeaderName` compares and hashes by `#folded`, keeps `#original` for emission, interoperates with the string-keyed API (`HeaderName.of` accepts a `String` or a `HeaderName`; every `Headers` method accepts either), and enforces `HTTP-17`. The fold is a derived attribute set before `super`, not a second member (`P1-11`) (`header_name_test.rb`) | | `HTTP-22` | MAY | ⏳ | — | Not built; the observable contract — value equality by folded name — holds without interning. Owned by `docs/first-release.md` § Blockers before first publish, the `HTTP-22`/`48`/`49`/`50` decision line | | `HTTP-23` | MUST | ✅ | 9 | `MediaType.parse` lower-cases type, subtype and every parameter key and preserves each value's case; `initialize` re-checks that every component is already folded, so `#with(type: "TEXT")` raises. Equality falls out of the members: case-insensitive where folded, case-sensitive on values (`media_type_test.rb`) | | `HTTP-24` | MUST | ✅ | 9 | `MediaType#charset` looks `charset` up under the folded key, folds the value, and returns it only when this Ruby's `Encoding.name_list` (minus the four process-relative pseudo-aliases) knows it; `nil` otherwise, never a raise; the `nil` is documented at the accessor (`media_type_test.rb`) | | `HTTP-25` | MUST | ✅ | 9 | A `StringScanner` parser over the byte-validated input: parameters split on `;` outside a quoted-string, each on its **first** `=`, quotes stripped and quoted-pairs unescaped; `#render` emits a value bare when it is a token and quoted-and-escaped otherwise. `parse(render(x)) == x` is a 64-sample property over an alphabet including `;`, `=`, `"` and `\` (`media_type_test.rb`) | -| `HTTP-26` | MUST | ✅ | 3, 9 | `MediaType.parse` runs `HeaderSyntax.valid_outbound_value?` — the same predicate, not a copy — on the raw input before any character-oriented work, and `initialize` runs it on every parameter value; CR, DEL, non-ASCII and invalid UTF-8 are rejected with the SDK's error (`media_type_test.rb`) | +| `HTTP-26` | MUST | ✅ | 3, 9 | `MediaType.parse` runs `HeaderSyntax.valid_outbound_value?` — the same predicate, not a copy — on the raw input before any character-oriented work, and `initialize` runs it on every parameter value; CR, DEL, non-ASCII and invalid UTF-8 are rejected with the SDK's error, and input that passes under a tag the scanner cannot match against is retagged first (deviation 22) (`media_type_test.rb`) | | `HTTP-27` | SHOULD | ✅ | 9 | `#matches?`: `*/*` matches everything, `type/*` any subtype of the type, parameters ignored; `*/json` is rejected at construction, and a non-`MediaType` operand is refused with the SDK's error rather than a `NoMethodError` (`media_type_test.rb`) | | `HTTP-28` | MUST | ✅ | 11 | `Query` is a list of `[name, value]` pairs: case-sensitive names, insertion order across names, multiple values per name, and `Query::Builder#add(name, nil)` stores `""` — one empty-string value, distinct from an absent name (`query_test.rb`, `query/builder_test.rb`) | | `HTTP-29` | MUST | ✅ | 10, 11 | `Query#encode` renders every name and value through `PercentEncoding.encode_component`, one occurrence per value, in insertion order, no leading `?`, `""` when empty (`query_test.rb`) | | `HTTP-30` | MUST | ✅ | 11 | `Query#==`/`#hash` compare the encodings, which is the requirement stated literally (`P1-12`); order-sensitive, and `parse(q.encode) == q` is a 64-sample property. An empty value list cannot reach the model: `Query::Builder#set(name, [])` removes the name (`query_test.rb`, `query/builder_test.rb`) | | `HTTP-31` | MUST | ✅ | 10, 11 | `Query.parse`: `nil` or blank → `Query::EMPTY`, a leading `?` tolerated, a segment with no `=` or a trailing `=` → `""`, a stray `&` skipped, a malformed escape kept raw; `PercentEncoding.decode_component` is lenient and total. Both read bytes, so a query carrying invalid UTF-8 parses and re-encodes byte-exactly (`query_test.rb`, `percent_encoding_test.rb`) | | `HTTP-32` | SHOULD | ✅ | 10 | `PercentEncoding.encode_component`: unreserved set exactly `A-Za-z0-9-._~`, everything else uppercase-escaped — `a b*~+/!()'` → `a%20b%2A~%2B%2F%21%28%29%27` byte for byte; `decode_component("a+b")` is `"a+b"`; `%FF` round-trips (`percent_encoding_test.rb`) | -| `HTTP-33` | MUST | ✅ | 8 | `Protocol` over the canonical forms `http/1.1` and `http/2`; `Protocol.parse` accepts those and the aliases `HTTP/2` and `HTTP/2.0` case-insensitively (`downcase`, no argument, after a byte check) and raises naming an unrecognised identifier; `.build` accepts only a canonical form, so `#with` cannot admit an alias (`protocol_test.rb`) | +| `HTTP-33` | MUST | ✅ | 8 | `Protocol` over the canonical forms `http/1.1` and `http/2`; `Protocol.parse` accepts those and the aliases `HTTP/2` and `HTTP/2.0` case-insensitively (`downcase`, no argument, on the bytes, after a byte check — deviation 22) and raises naming an unrecognised identifier; `.build` accepts only a canonical form, so `#with` cannot admit an alias (`protocol_test.rb`) | | `HTTP-34` | MUST | ✅ | 13 | `RequestOptions` — `timeout` (a `Float` of seconds), `max_retries`, `tags` — every field `nil`/empty by default, `RequestOptions::EMPTY` one shared frozen instance, `tags` `Model.own`ed at construction and a map of `String` to `String`. **Tag values are `String` only, and that is a reading, not an oversight**: `HTTP-34` says "opaque string-keyed tags" and `HTTP-1`'s carve-out leaves a tag value's own mutability to the caller, so an arbitrary object would be admissible; the design fixed `String → String` so `Model.own` can deep-copy and deep-freeze the map without meeting an object it cannot copy (a `Thread`, a `Proc` — the failure deviation 21 removes for collections generally), and the conservative direction is the safe one to widen later. Ledger row `P1-14`; a phase that needs a non-`String` tag value reopens it there (`request_options_test.rb`) | -| `HTTP-35` | MUST | ✅ | 13 | In the model's `initialize`, so `.build` and `#with` meet it: a non-nil timeout that is zero or negative raises, a negative `max_retries` raises, `0` is accepted and means "disable retries for this call" (`request_options_test.rb`) | +| `HTTP-35` | MUST | ✅ | 13 | In the model's `initialize`, so `.build` and `#with` meet it: a non-nil timeout that is zero or negative raises, a negative `max_retries` raises, `0` is accepted and means "disable retries for this call". A timeout that is not a finite real number — a `Complex`, `Float::INFINITY`, `NaN` — raises the same error rather than escaping as a `NoMethodError` or reaching a socket API (deviation 22) (`request_options_test.rb`) | | `HTTP-46` | MUST | ✅ (URL and method/headers), ⏳ (body by value) | 12, 14 | `Request#==`/`#hash` compare `URL.external_form(url)` — a textual key; Ruby's `URI` resolves nothing, and the test compares two textually different URLs naming one host without touching the network — plus method, headers and body by `==` (`P1-6`). The body half is opaque here and was built by phase 3b (`P3-15`, `docs/work/mvp/phase3/phase3b/2026-09-08-phase3b-body-lifecycle.md`) (`url_test.rb`, `request_test.rb`) | | `HTTP-47` | SHOULD | ✅ | 12, 14 | `URL.parse!` pins `::URI::RFC3986_PARSER`, refuses a non-absolute URI naming it, and converts `URI::InvalidURIError` into a `Dexpace::InvalidArgumentError` carrying the offending input with the original as `cause`; `Request::Builder#build` and `Request#initialize` both route the URL through it, so `"::bad"` is named from either (`url_test.rb`, `request/builder_test.rb`) | | `HTTP-48` | SHOULD | ⏳ | — | Not built; `docs/first-release.md` § Blockers before first publish, the `HTTP-22`/`48`/`49`/`50` decision line — no v1 phase constructs a conditional request | @@ -64,8 +64,9 @@ Forty-two: `HTTP-1`–`HTTP-35`, `HTTP-46`–`HTTP-50`, `HTTP-53` and `SEAM-29`. | `HTTP-53` | MUST | ✅ | 9 | `MediaType.parse` rejects blank input, a missing or empty type or subtype, more than one `/`, a parameter with no `=`, an empty key or an empty value, an unterminated quoted-string and text after a closed one; the rejected list is enumerated in `media_type_test.rb` | | `SEAM-29` | MUST | ✅, both halves | 1, 2, 5, 11, 13, 14, 15 | The uniform message: `Model.required!` is the one helper, one error class, one form (`model_test.rb`). The generic contract: `Dexpace::Builder`, included by all five builders, with `Dexpace::_Builder[T]` as the RBS interface and `Builder.build_all` as the composition helper; `dexpace/builder_test.rb` passes all five builders through it — the requirement's conformance step — and proves an incomplete builder raises `NotImplementedError` and a non-builder is refused | -Forty-two rows: 36 ✅ (three of them by construction or split — `HTTP-1`, `HTTP-2`, `HTTP-3`; two carrying a -deferred half — `HTTP-3`, `HTTP-46`), 4 ⏳ (`HTTP-22`, `HTTP-48`, `HTTP-49`, `HTTP-50`), 0 🚫, 0 N/A. +Forty-two rows: 38 ✅ (two of them by construction — `HTTP-1`, `HTTP-2`; two split with a deferred half — +`HTTP-3`, `HTTP-46`), 4 ⏳ (`HTTP-22`, `HTTP-48`, `HTTP-49`, `HTTP-50`), 0 🚫, 0 N/A — 38 + 4 = 42, recounted +from the table after the round-3 review found the earlier sentence's 36 short of its own rows. `XCUT-15` and `XCUT-18` are implemented here — `Model.own`/`Model.frozen_string` and `HeaderSyntax` — and are phase 9's to disposition; neither is a row. @@ -78,9 +79,9 @@ Module Layout section names, file for file. `require "uri"` and `require "strsca and both are on the require allowlist). The gemspec still has zero `add_dependency` lines. The gates, all seventeen, on **4.0.6** (`bundle exec rake`, 2026-09-15): green, exit 0 — `cops:test` 65 runs, -`steep` no type error over the strict `core` target, `test:gems` 260 runs / 1897 assertions with **100.00% line -coverage (780/780)** against the 80% floor, `test:gates` 128 runs / 523 assertions, the nine `gates:*` tasks, -`yard` 100.00% documented (15 modules, 16 classes, 50 constants, 8 attributes, 122 methods), `bundler_audit` +`steep` no type error over the strict `core` target, `test:gems` 273 runs / 1956 assertions with **100.00% line +coverage (789/789)** against the 80% floor, `test:gates` 128 runs / 523 assertions, the nine `gates:*` tasks, +`yard` 100.00% documented (15 modules, 16 classes, 50 constants, 8 attributes, 123 methods), `bundler_audit` clean. **One caveat about `rubocop`, stated in full under "Findings routed"**: run through `rake` from this worktree it inspected 8 files, because RuboCop inherits `AllCops/Exclude` from the topmost `.rubocop.yml` on the path and the parent checkout's excludes `.claude/**/*`, under which this worktree sits; run as @@ -95,10 +96,11 @@ the override removed (`Dexpace::Model.send(:remove_method, :with)` after load) ` the design's finding 1 reproduced against the built code. The surface manifest `test/fixtures/surface/dexpace-core.txt` grew from two lines to 221 through -`bundle exec rake surface:regenerate`, once, after the last task, and to 211 after the round-1 review made -ten parser and codec constants `private_constant` (deviation 18); each diff was read line by line and the -manifest holds exactly the public constants and methods the twenty-two files define — -`Headers::EMPTY_INBOUND` beside `Headers::EMPTY`, `Method::IDEMPOTENT` (`P1-10`), and no private reader +`bundle exec rake surface:regenerate`, once, after the last task, to 211 after the round-1 review made +ten parser and codec constants `private_constant` (deviation 18), and to 212 after the round-3 review added +`HeaderSyntax.ascii_compatible` (deviation 22); each diff was read line by line and the manifest holds +exactly the public constants and methods the twenty-two files define — `Headers::EMPTY_INBOUND` beside +`Headers::EMPTY`, `Method::IDEMPOTENT` (`P1-10`), `HeaderSyntax#ascii_compatible`, and no private reader (deviation 10 below). `gates:sig_diff` still prints "no release tag yet". ## Audit groups run @@ -112,7 +114,7 @@ contradicts this plan. `--req` was run for each task's IDs before that task. The | Public API surface | Clean, with one rule the plan's own text got backwards: `data-modeling/3775e9d7` says *never* `module_function`, and the plan's Task 3 aside said `module_function` "rather than `extend self`" citing that same key. The corpus and `.rubocop.yml`'s `Style/ModuleFunction: extend_self` agree; the three function modules use `extend self` (deviation 3). `api-design/e4fa3438`'s override-with-a-why-comment is used four times — `HeaderName`, `Headers`, `Query`, `Request` — one more than the design counted, for `Query`'s equality-by-encoding (`P1-12`) | | RBS / Steep typing | Two facts phase 0 could not have seen, both about rbs's core signatures: `Data` declares no `initialize`, so a keyword `super` in any `initialize` override resolves to `BasicObject#initialize` and Steep rejects it — declared once on `Dexpace::Model` in RBS only (deviation 5); and `URI::RFC3986_Parser` is an empty class, so its `#parse` is untyped at the one call site. Strict Steep also refuses an unannotated `[]`/`{}` (`Ruby::UnannotatedEmptyCollection`), whose annotation syntax `#:` the RuboCop baseline rejected — `Layout/LeadingCommentSpace: AllowRBSInlineAnnotation: true` (deviation 7). `NFR-11`'s allowlist lacked `Data`, `ArgumentError` and `StringScanner` (deviation 6) | | Minitest conventions | Clean. `testing/62f8f4ec`'s error-boundary pairing is applied to every builder — class, field-naming message, no partial side effect — and `testing/f36a19cd`'s bounded property tests exist for `HeaderName`'s fold, `Status`'s totality, `HeaderSyntax`'s grammar ordering, `PercentEncoding`, `MediaType` and `Query`. Three suites carry a nested test class per behaviour group (deviation 16), because `Style/OneClassPerFile` forbids a second top-level class and `Metrics/ClassLength` caps one at 100 lines | -| Encoding and binary strings | Clean and load-bearing: every validator reads `value.b.each_byte`; `PercentEncoding` accumulates into a BINARY buffer and retags UTF-8 at the end; `Query.parse` and `HeaderSyntax.trim` work on `.b`; `HeaderSyntax.validate_name!` retags a *valid* name with the caller's encoding because its bytes are proven printable ASCII, which is `io-and-byte-streams/0a4773a6`'s "retag only where the bytes are known to conform" (deviation 4) | +| Encoding and binary strings | Clean and load-bearing: every validator reads `value.b.each_byte`; `PercentEncoding` accumulates into a BINARY buffer and retags UTF-8 at the end; `Query.parse` and `HeaderSyntax.trim` work on `.b`; `HeaderSyntax.validate_name!` retags a *valid* name with the caller's encoding because its bytes are proven printable ASCII, which is `io-and-byte-streams/0a4773a6`'s "retag only where the bytes are known to conform" (deviation 4) — and, since the round-3 review, only when that encoding can carry them: a stateful or wide tag over ASCII bytes conforms to nothing Ruby will fold under, so `HeaderSyntax.ascii_compatible` retags those US-ASCII before every fold, upcase and scan in core (deviation 22) | Three of the four notes the design filed are unchanged by implementation. The fourth — the Ractor entry in `docs/knowledge/notes/data-modeling.md` — is rewritten as built: it now stands under `## Conflicts`, records @@ -127,7 +129,8 @@ wrong is the one the ledger row `P1-9` rested on. The rewrite retires the planni Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate; three change a gate or tool in the strict or the corrected direction, each with a fixture. Items 20 and 21 -are the round-2 review's repairs, landed on 2026-09-15 with the test counts above re-derived afterwards. +are the round-2 review's repairs and item 22 the round-3 review's, each landed on 2026-09-15 with the +test counts above re-derived afterwards. 1. **`Dexpace::Model#with(changes = nil)` takes a positional `Hash`, not `**changes`.** `Dexpace/NoKeywordSplat` flags every public `def` in `gems/*/lib` with a `**` parameter, and an inline disable would special-case a @@ -145,8 +148,9 @@ are the round-2 review's repairs, landed on 2026-09-15 with the test counts abov under a `private` section with a private RBS declaration. The corpus rule the plan cited says the opposite of what the plan's aside claimed, and `Style/ModuleFunction` enforces the corpus. 4. **`HeaderSyntax.trim` returns BINARY bytes; `validate_name!` retags a valid name with the caller's - encoding**; `HeaderSyntax.token?` and `TCHAR` live in `HeaderSyntax`, shared by `Method` and `MediaType`, - rather than a `TCHAR` in `Method` alone. One grammar, one home. + encoding** — when that encoding can carry ASCII, and with US-ASCII when it cannot (item 22); + `HeaderSyntax.token?` and `TCHAR` live in `HeaderSyntax`, shared by `Method` and `MediaType`, rather than a + `TCHAR` in `Method` alone. One grammar, one home. 5. **RBS: `module Model : _ModelInstance`** — an interface asking for `to_h` and an untyped `class`, because a module cannot name every includer's own `.build` keyword list and Steep checks the self-type at every `include`; and **`Model#initialize: (**untyped) -> void` declared in RBS only**, because rbs core declares no @@ -248,6 +252,34 @@ are the round-2 review's repairs, landed on 2026-09-15 with the test counts abov collections duplicated and frozen exactly once, at construction — is unchanged; only the line within construction moved. The forged `send(:new, …)` path now owns its collections too, which narrows `HTTP-2`'s admitted gap by one aliasing hazard without claiming to close it. +22. **A String whose bytes pass a byte check under a tag Ruby cannot fold, upcase or scan under is retagged, + not crashed on** — the round-3 review's finding, the mirror image of the design's finding 3. Every + validator reads bytes, so `"Accept".encode("ISO-2022-JP")` — the same six bytes as the literal, under a + stateful (dummy) tag — passed `valid_name?`, and `"Accept".b.force_encoding("UTF-16LE")` passed it too; + `validate_name!` then retagged the trimmed bytes with that encoding, and `HeaderName`'s `downcase`, + `Method`'s `upcase`, `Protocol.parse`'s `downcase` and `MediaType.parse`'s `StringScanner` each raised + `Encoding::CompatibilityError` from inside Ruby, escaping `rescue Dexpace::Error` from `HeaderName.of`, + `Headers::Builder#add`, `Headers#[]`, `Headers#include?`, `Method.of`, `Protocol.parse`, + `MediaType.parse` and `Request.build(method:)` (probed on 3.2.11 and 4.0.6). The one place that is + normalised is **`HeaderSyntax.ascii_compatible(text)`**, public for the reason the rest of the module is: + it returns the String itself when its encoding is ASCII-compatible — every such encoding spells ASCII + identically, so the common path allocates nothing — and otherwise a copy of the bytes tagged US-ASCII + when they are ASCII and left BINARY when they are not, never retagging the caller's object in place. + The predicate is `Encoding#ascii_compatible?`, not `dummy?`: every dummy encoding is ASCII-incompatible, + and the four wide ones (UTF-16/32 BE/LE) are not dummies yet fold six ASCII bytes into three CJK + characters that no UTF-8 lookup would ever find. `validate_name!` returns the trimmed bytes under the + tag `ascii_compatible` gives the caller's String; `Method#initialize` upcases through it; + `MediaType.parse` scans through it and `MediaType#initialize` stores every component, key and value + through it, since `#render` interpolates all of them and `#charset` folds one; `Protocol.parse` folds + the bytes untagged, because its lookup key needs no tag. Header **values** are not retagged — core never + folds or scans one — and `Query.parse` and `URL.parse!` already worked on bytes or refused the tag with + the SDK's error. One new public method, so the surface manifest was regenerated deliberately (212 + lines) and `sig/` gained the signature in the same change. Two eager checks the same review asked for + ride with it, in the shape of item 15: `RequestOptions` refuses a timeout that is not a finite real + number — a `Complex` has no `#positive?` and escaped as `NoMethodError`; `Float::INFINITY` was stored + and would have reached a socket API — and `Model#with` refuses a positional that is not a `Hash`, the + call the `**changes` form would have rejected at the call site and the positional form (item 1) let + through to a `NoMethodError` from `empty?`. Both are the Task 1 rule applied. ## Findings routed @@ -260,6 +292,15 @@ are the round-2 review's repairs, landed on 2026-09-15 with the test counts abov as-built note on `P1-9`, and as a corpus note; `docs/deviations.md` is phase 10's to flip and is untouched. - **`Dexpace::Protocol` has no alias for `http/1.0`** — already on phase 10's inbound list from phase 8a; phase 1 builds `HTTP-33` as stated and leaves the widening to that decision. +- **`URL.parse!` accepts a host-less `http:` and any absolute non-HTTP URI (`mailto:x@y`), so a `Request` can + carry a URL no transport can dispatch.** `URI::Generic#absolute?` is only "a scheme is present", which is + what `HTTP-47`'s letter — malformed or non-absolute — asks; its rationale, failing here "rather than + surfacing a lower-level or transport-specific error later", is not met for those two shapes, which today + would be discovered at the first `Net::HTTP.new(url.hostname, …)` (a round-3 review nit). Not a phase-1 + requirement violation, and the rejection belongs to whoever knows what can be dispatched — phase 8a's + `RequestMapper`/`Adapter` (Tasks 16 and 19), or `URL.parse!` if a transport-independent rule is preferred — + so the decision is routed to phase 10's inbound list in + `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md` rather than left to be found at dispatch. - **Nine later-phase design documents cite `data-modeling/5bc538ba` as narrowing the shareability claim** — the phase 3, 4, 5 and 7 segmentation designs, 4a, 4b, 5a, 5b and 8a — and that key no longer resolves: the planning-time note it named is withdrawn, as built, by the `## Conflicts` entry above, which says the