Skip to content

Phase 1: core HTTP domain model — the wire model - #40

Merged
Wahbeh-Mohammad merged 6 commits into
mainfrom
8-phase-1-core-http-domain-model
Sep 15, 2026
Merged

Wahbeh-Mohammad merged 6 commits into
mainfrom
8-phase-1-core-http-domain-model

Conversation

@Wahbeh-Mohammad

@Wahbeh-Mohammad Wahbeh-Mohammad commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Part of #8. First of three phase 1 PRs — the code. Its tests are #41 and the phase record is #42. First phase that ships domain code.

What lands

The frozen, transport-agnostic HTTP wire model in dexpace-core — 53 files, +2,808:

  • The construction contract — Dexpace::Error (a module), Dexpace::InvalidArgumentError < ::ArgumentError, Dexpace::Model (required! with the one <name> is required form SEAM-29 fixes, own — every collection duplicated and deep-frozen exactly once at construction via Ractor.make_shareable(copy: true), frozen_string, and a #with that routes through .build so it re-validates on Ruby 3.2, where Data#with skips an initialize override), and the shared Dexpace::Builder contract. Every model is class X < Data.define(...) with private_class_method :new and a validating .build; the residual send(:new, …) / duck-type gap is asserted in the tests rather than hidden (design §10 item 10).
  • Header validation — HeaderSyntax: byte-level predicates (.b.each_byte, never a character regexp) for HTTP-17–HTTP-20, public because phase 8's adapters re-run them at the wire boundary; the String#strip-strips-NUL trap avoided. HeaderName with HTTP-13's locale-free fold; Headers + Headers::Builder with direction (inbound/outbound), multi-value and ordering semantics.
  • The value types — Status (total over 100–599), Method (HTTP-7/HTTP-8 body legality; IDEMPOTENT public per P1-10), Protocol, MediaType (a StringScanner parser over per-pattern-timeout regexps, run only after the byte check), PercentEncoding (design §3.5's strict component encoder, byte-based), Query + Query::Builder (equality by encoding, P1-12), URL (every parse pinned to URI::RFC3986_PARSER; re-parses a URI input from its text and freezes the component Strings it owns, because URI#dup shares @host/@path with the caller — P1-13), RequestOptions + builder (HTTP-35), Request + builder, Response + builder.
  • Signatures and docs — sig/ mirrors lib/ one file per file; YARD 100% (15 modules, 16 classes, 50 constants, 8 attributes, 123 methods); the runtime-surface manifest regenerated deliberately (2 → 212 lines).
  • Four gate/tool corrections the first real signatures exposed, each recorded as a checklist deviation: tools/rbs_surface.rb's stdlib allowlist gains Data, ArgumentError, StringScanner (a false positive on class X < Data); gates:single_instance reports the superclass mismatch a double-loaded Data.define raises; Surface.data_readers honours a privatised reader; rbs:validate loads the allowlisted stdlib signature sets with -r. Plus Layout/LeadingCommentSpace: AllowRBSInlineAnnotation: true, because strict Steep needs #: annotations on empty literals.

Layering, and why this PR's CI is red

Each tip of the stack is green under every gate on its own tree with one stated exception: on this branch test:gems fails on the SimpleCov minimum_coverage 80 floor alone — 60.58%, 20 runs, 0 failures — because the suites that raise coverage are #41's by definition. The other sixteen gates were run individually and are green (rubocop, cops:test, rbs:validate, steep, test:gates, all nine gates:*, yard, bundler_audit). #41 takes the same tree to 100% line coverage (789/789) and is fully green on 4.0.6 and on the 3.2.11 floor. The two gate test files here (test/gates/single_instance_test.rb, gems/dexpace-core/test/dexpace_test.rb) are the ones the code alone breaks.

Verification

  • Independent reviewer, three fix rounds; every finding from rounds 0–2 verified resolved on re-review.
  • Construction pattern and HTTP rules proven by experiment on both 3.2.11 and 4.0.6 (scratch probes, identical output): #with re-validates on the floor; every rejection is Dexpace::InvalidArgumentError, never an ArgumentError/Encoding::CompatibilityError leaking from inside Ruby on invalid UTF-8, CR, LF or NUL; HTTP-7/HTTP-8 via builder, .build and #with; I/İ/ı/ß per the design; no URI.parse/URI.join/DEFAULT_PARSER, no Regexp.timeout=, no Time.parse, no interrupts.
  • Every model is Ractor.shareable? as built, Request/Response included (with a nil or frozen body) — design §4's claim stands in full; the planning-time narrowing P1-9 is retired (Phase 1: core HTTP domain model — documentation and phase record #42 records it).

Known follow-ups from the final review (not blocking)

  • Model.own on a Hash with a default_proc (Hash.new { |h, k| h[k] = [] }, the ordinary multimap idiom): passes the shape checks, then Ractor.make_shareable(copy: true) raises TypeError: allocator undefined for Proc, which escapes rescue Dexpace::Error from Headers.build(values:/casing:), RequestOptions.build(tags:) and RequestOptions::Builder#build. Identical on 3.2.11 and 4.0.6. Should be caught and re-raised as InvalidArgumentError (or the proc dropped) in Model.own.
  • HeaderSyntax.valid_name?(nil), token?(nil), validate_outbound_value!(1, …) and Headers::Builder.new(values: 5) raise NoMethodError rather than the SDK's error — these are exactly the predicates phase 8 re-runs on a possibly forged model, so a String === guard would keep the Task 1 rule uniform.
  • Query.parse uses text.b.strip, which drops a raw leading/trailing NUL; the header path deliberately trims SP/HTAB only. Either match, or state it in the YARD block.
  • Commit subjects on the repair commits run long; squash-merge title is the PR's.

Twenty-two files under gems/dexpace-core/lib/dexpace/, each with its sig/
mirror: the error root and InvalidArgumentError, the Model and Builder
construction contract, HeaderSyntax, HeaderName, Headers, Status, Method,
Protocol, MediaType, PercentEncoding, Query, URL, RequestOptions, Request
and Response. The entry file requires them in dependency order, the smoke
suite's namespace snapshot admits them, and the runtime surface manifest
is regenerated once, deliberately, from the built tree.

Satisfies HTTP-3 through HTTP-35, HTTP-46, HTTP-47 and HTTP-53, with
HTTP-1, HTTP-2 and SEAM-29 by construction; HTTP-22 and HTTP-48 to
HTTP-50 stay under docs/first-release.md.

What the first real signatures and models taught the gates, in the same
change because the tree is not green without it: gates:rbs_surface admits
Data, ArgumentError and StringScanner (core Ruby, or strscan on the
require allowlist); rbs:validate loads the allowlisted stdlib signature
sets it never had to before; the surface walker lists a Data.define
reader only while the model keeps it public; gates:single_instance names
the superclass mismatch a second copy of a Data.define model raises
instead of dying on it, with its test adjusted to that message; and
Layout/LeadingCommentSpace admits Steep's inline annotation, which strict
Steep requires on an empty literal.
…ion is validated before it is owned (HTTP-18, XCUT-18)
…le tag, so a stateful-encoding String is accepted rather than crashed on (HTTP-13, HTTP-17)
@Wahbeh-Mohammad Wahbeh-Mohammad added this to the v1/mvp milestone Sep 15, 2026
@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* labels Sep 15, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad added this pull request to stack #43 September 15, 2026 09:06
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit fcbf899 into main Sep 15, 2026
1 of 5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 8-phase-1-core-http-domain-model branch September 15, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant