Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5ffc20f
feat: seam failure types, the close contract and the cancellation token
Wahbeh-Mohammad Sep 15, 2026
29949c5
feat: the core-owned async pivot and the qualified-core-constant cop
Wahbeh-Mohammad Sep 15, 2026
3218a62
feat: the registry, the three seams, the bridges and the operation seam
Wahbeh-Mohammad Sep 15, 2026
b952606
chore: regenerate the surface manifests and extend the smoke suite
Wahbeh-Mohammad Sep 15, 2026
e068849
fix: refuse a transport callable with a required keyword
Wahbeh-Mohammad Sep 15, 2026
232fc86
fix: refuse a template literal that is not a URI path, and an opaque …
Wahbeh-Mohammad Sep 15, 2026
93409bd
fix: refuse a base URL whose query is not RFC 3986, before composing …
Wahbeh-Mohammad Sep 15, 2026
4c71e79
test: the seam layer's suites, the in-memory fakes and the cop's cases
Wahbeh-Mohammad Sep 15, 2026
1365758
test: a required keyword fails conforms?, and a nil header input is a…
Wahbeh-Mohammad Sep 15, 2026
2e835a7
test: a template literal that is not a URI path, and an opaque base, …
Wahbeh-Mohammad Sep 15, 2026
01380c0
test: a base whose query is not RFC 3986 is refused, and a base property
Wahbeh-Mohammad Sep 15, 2026
664d623
test: a conflict message that reaches back into the registry is built…
Wahbeh-Mohammad Sep 15, 2026
1e9ed00
docs: phase 2 checklist, ledger notes, status note and the seams page
Wahbeh-Mohammad Sep 15, 2026
9d8fad4
docs: correct the swap sentence, the cop ordinal and the file count
Wahbeh-Mohammad Sep 15, 2026
c13c13b
docs: recount the checklist roll-up, record the template and opaque-b…
Wahbeh-Mohammad Sep 15, 2026
b7e6fb4
docs: record the base-query rule and the registry conflict paths, rec…
Wahbeh-Mohammad Sep 15, 2026
99a75ad
docs: recount the gate run to the round-3 suite
Wahbeh-Mohammad Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require:
- ./.rubocop/cops/dexpace/no_locale_case_fold.rb
- ./.rubocop/cops/dexpace/no_thread_interrupt.rb
- ./.rubocop/cops/dexpace/no_keyword_splat.rb
- ./.rubocop/cops/dexpace/qualified_core_constant.rb

AllCops:
NewCops: enable
Expand Down Expand Up @@ -171,3 +172,14 @@ Dexpace/NoKeywordSplat:
Enabled: true
Include:
- "gems/*/lib/**/*.rb"
# Design §9 Addendum A1 (phase 2): a bare Thread/Queue/Mutex/JSON inside these two namespaces
# rebinds to the adapter gem's constant the moment that gem is required, and core's own suite
# never requires it.
Dexpace/QualifiedCoreConstant:
Enabled: true
Include:
- "gems/dexpace-core/lib/dexpace/async/**/*.rb"
- "gems/dexpace-core/lib/dexpace/serde/**/*.rb"
# The seam module itself reopens Dexpace::Serde from one directory up, so a bare JSON there
# is the same hazard as one inside serde/.
- "gems/dexpace-core/lib/dexpace/serde.rb"
80 changes: 80 additions & 0 deletions .rubocop/cops/dexpace/qualified_core_constant.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

module RuboCop
module Cop
module Dexpace
# Inside `module Dexpace::Async` and `module Dexpace::Serde`, a bare `Thread`, `Queue`,
# `Mutex`, `SizedQueue`, `ConditionVariable` or `JSON` is a silent time bomb: it resolves to
# Ruby's class until the adapter gem that defines `Dexpace::Async::Thread` or
# `Dexpace::Serde::JSON` is required, and to that module afterwards.
#
# Verified on Ruby 3.2.11 and 4.0.6: before the adapter loads, a bare `Thread` inside
# `module Dexpace; module Async` is `Thread`; after, it is `Dexpace::Async::Thread`, and
# `Thread.new` raises `NoMethodError: undefined method 'new' for module
# Dexpace::Async::Thread`. dexpace-core's own suite never requires that gem, so this is a bug
# that cannot fail in the tree that contains it -- which is what a cop is for.
#
# Scoped by `Include:` in .rubocop.yml to gems/dexpace-core/lib/dexpace/{async,serde}/**/*.rb.
#
# The enclosing namespace is checked here rather than left to `Include:` in .rubocop.yml.
# Phase 0's cop harness runs a bare Commissioner over a fixed path with a Config carrying
# only TargetRubyVersion, so no `Include:` ever applies to a cop under test -- a cop that
# relied on path scoping would flag its own accepted case and the suite would say so.
# `Include:` stays in .rubocop.yml anyway, because it is what keeps the cop off the other
# five gems during a real run.
#
# SHADOWED is one list for both watched namespaces rather than one list each, so a bare
# `JSON` inside Dexpace::Async is flagged even though only Dexpace::Serde reopens JSON. Two
# per-namespace lists would be a second rule to keep in step for no gain, and the false
# positive costs one `::`.
#
# @example
# # bad -- inside module Dexpace; module Async
# Queue.new
#
# # good
# ::Queue.new
#
# # good -- a different namespace; nothing reopens Dexpace::Transport::Thread
# Thread.new
class QualifiedCoreConstant < Base
MSG = "Write `::%<name>s` here: a bare `%<name>s` inside %<namespace>s rebinds to the " \
"adapter gem's constant once that gem is required."

SHADOWED = %w[Thread Queue Mutex SizedQueue ConditionVariable JSON].freeze

WATCHED = [%w[Dexpace Async], %w[Dexpace Serde]].freeze

def on_const(node)
return if node.namespace # already qualified: `A::Thread` or `::Thread`
return unless SHADOWED.include?(node.short_name.to_s)

namespace = watched_namespace(node)
return if namespace.nil?

add_offense(node, message: format(MSG, name: node.short_name, namespace: namespace))
end

private

# @return [String, nil] the watched namespace this node is lexically inside, or nil
def watched_namespace(node)
path = lexical_path(node)
watched = WATCHED.find { |segments| path.each_cons(segments.length).any?(segments) }
watched&.join("::")
end

# Outermost-first, with a compact `module A::B` split into its segments, so that both
# `module Dexpace; module Async` and `module Dexpace::Async` read the same. A
# `class << self` is an sclass node, not a class node, so it contributes no segment.
def lexical_path(node)
node.each_ancestor(:module, :class)
.map { |scope| scope.identifier.source }
.reverse
.flat_map { |name| name.split("::") }
end
end
end
end
end
54 changes: 53 additions & 1 deletion .rubocop/test/cops_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

# The original five cops and Dexpace/NoKeywordSplat, which between them mechanise CLAUDE.md's
# ban list and NFR-13. One row per case, one generated Minitest method per row, so a failure
# names exactly one input.
# names exactly one input. Phase 2's sixth cop has its own tables in the nested class below,
# because Metrics/ClassLength caps one class at 100 lines and counts a table row as a line.
class CopsTest < CopCase
D = RuboCop::Cop::Dexpace
HEADER = "# frozen_string_literal: true\n# SPDX-License-Identifier: MIT\n\n"
Expand Down Expand Up @@ -127,4 +128,55 @@ class CopsTest < CopCase
assert_no_offense(cop, source)
end
end

# Design §9 Addendum A1 (phase 2): a bare Thread/Queue/Mutex/JSON inside Dexpace::Async or
# Dexpace::Serde rebinds to the adapter gem's constant the moment that gem is required, and
# core's own suite never requires it. The compact `module Dexpace::Async` form is the same
# lexical path and is flagged too; one SHADOWED list serves both namespaces, so a bare JSON
# inside Dexpace::Async is flagged even though only Dexpace::Serde reopens JSON.
class QualifiedCoreConstantTest < CopCase
ASYNC = "module Dexpace\n module Async\n %s\n end\nend\n"
SERDE = "module Dexpace\n module Serde\n %s\n end\nend\n"
NESTED = "module Dexpace\n module Async\n class Completer\n def initialize\n " \
"@gate = %s\n end\n end\n end\nend\n"

# [source, the fragment the message must carry]
REJECTED = [
[format(ASYNC, "Thread.new"), "Write `::Thread` here"],
[format(ASYNC, "Queue.new"), "Write `::Queue` here"],
[format(ASYNC, "Mutex.new"), "Write `::Mutex` here"],
[format(ASYNC, "SizedQueue.new(2)"), "Write `::SizedQueue` here"],
[format(ASYNC, "ConditionVariable.new"), "Write `::ConditionVariable` here"],
[format(SERDE, "JSON.generate(value)"), "Write `::JSON` here"],
["module Dexpace::Async\n Thread.new\nend\n", "Write `::Thread` here"],
[format(ASYNC, "JSON.parse(text)"), "Write `::JSON` here"],
# A class nested inside the namespace is still lexically inside it, and `Thread::Queue`
# is a bare `Thread` with `Queue` hanging off it.
[format(NESTED, "Thread::Queue.new"), "Write `::Thread` here"],
].freeze

# The accepted half is what proves the cop does not reject every occurrence of the six names.
ACCEPTED = [
format(ASYNC, "::Thread.new"),
format(ASYNC, "::Queue.new"),
format(SERDE, "::JSON.generate(value)"),
"module Dexpace\n module Transport\n Thread.new\n end\nend\n",
"Thread.new\n",
format(ASYNC, "Dexpace::Async::Thread.new"),
format(NESTED, "::Thread::Queue.new"),
format(SERDE, "class << self\n def a = ::JSON\n end"),
].freeze

REJECTED.each_with_index do |(source, fragment), index|
test "rejects case #{index}: #{source.lines.first.strip}" do
assert_offense(D::QualifiedCoreConstant, source, fragment)
end
end

ACCEPTED.each_with_index do |source, index|
test "accepts case #{index}: #{source.lines.first.strip}" do
assert_no_offense(D::QualifiedCoreConstant, source)
end
end
end
end
50 changes: 37 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@ 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.

**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
**Phases 0, 1 and 2 are built; the domain model and the seam layer are 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`) — and the seam layer: the
provider registry `Dexpace::Registry`, the three seams `Dexpace::Transport`, `Dexpace::AsyncTransport` and
`Dexpace::Serde` with their `.conforms?` predicates and RBS interfaces, the two `SEAM-18` bridges under
`Dexpace::Bridge`, the core-owned async pivot `Dexpace::Async::Future` / `Completer` / `Settlement`, the
cooperative `Dexpace::Cancellation` token and its `Source`, `Dexpace::Closeable` with `Dexpace.close_quietly`,
the seam failure types `SeamError` / `ClosedError` / `CancelledError` and the `Serde::Error` hierarchy, and the
operation projection `Dexpace::Operation`
(`docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md`); every other gem's `lib/` still holds
its namespace module and a `VERSION` constant and nothing else. Nothing talks to a socket yet. 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,
Expand Down Expand Up @@ -348,6 +356,20 @@ Each is one line plus the chapter to read before touching the area.
(§4, §9).
- **Regexp timeouts are per-pattern** — `Regexp.new(source, timeout:)`, never the process-global
`Regexp.timeout`; a library must not impose a process-wide regexp budget on its host (§4, §6.3).
- **Inside `Dexpace::Async` and `Dexpace::Serde`, write `::Thread`, `::Queue`, `::Mutex`, `::SizedQueue`,
`::ConditionVariable` and `::JSON`** — a bare name resolves to Ruby's class until `dexpace-async-thread` or
`dexpace-serde-json` is required and to that gem's module afterwards, and core's own suite never requires
either. The seventh custom cop, `Dexpace/QualifiedCoreConstant`, enforces it over `lib/dexpace/async/`,
`lib/dexpace/serde/` and `lib/dexpace/serde.rb`; a stand-in `Dexpace::Async::Thread` in
`future_shadowing_test.rb` proves it behaviourally (phase 2's design, §9 addendum A1).
- **`SEAM-27`'s base-URL composition is a concatenation, not RFC 3986 reference resolution** —
`URI::RFC3986_PARSER.join("https://host/c?sig=1", "/pets")` is `https://host/pets`, dropping the base path
segment and the query the requirement keeps; `Dexpace::Operation` composes by hand. Reference resolution is
`REDIR-13`'s, phase 6, and its spelling there is `URI::RFC3986_PARSER.join`, because `URI.join` is cop-banned
(phase 2's design, §3 addendum A1, deviation P2-3).
- **A public `Data` follows the construction pattern without exception; only a `private_constant` snapshot is
exempt** — `Registry::State`, `Registry::Claim` and `Cancellation::Source::State` are `Data` without `Model`
and without `.build`, because a snapshot has no public constructor and no derivation (phase 2's P2-9).

## Public API surface

Expand Down Expand Up @@ -446,16 +468,18 @@ probe compares each against the live tree, and a count written anywhere else in
`dexpace-transport-net_http`, `dexpace-transport-async_http`, `dexpace-serde-json`, `dexpace-async-thread`
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
the phase-1 HTTP domain model and the phase-2 seam layer — forty-two phase-1 and phase-2 files under
`lib/dexpace/` beside phase 0's `version.rb`, every one mirrored in `sig/`, and every one of the forty-two
but the `private_constant` `hooks.rb` mirrored in `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/` 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,
`phase1/`, `phase2/`, `phase3/`, `phase4/`, `phase5/`, `phase6/`, `phase7/`, `phase8/`, `phase9/` and `phase10/`. `phase0/`, `phase1/` and `phase2/` each
carry that phase's design, plan and checklist — the three checklists written so far, each at
implementation; `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,
Expand Down Expand Up @@ -526,5 +550,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 and phase 1's is still to be written at execution time.
Every checklist but phase 0's, phase 1's and phase 2'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.
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ not compete with `faraday` or `httpx` on the easiest way to fetch a JSON endpoin

## Status

**Phases 0 and 1 are built.** Nothing is published. The repository holds six gems under `gems/`,
**Phases 0, 1 and 2 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:
types every later phase stands on (`docs/sdk-documentation/http.md`) — and the seam layer: the
provider registry, the transport and codec seams, the core-owned async pivot, cancellation, the
close contract and the operation projection (`docs/sdk-documentation/seams.md`); nothing talks to
a socket yet. The other five are still skeletons — a namespace, a `VERSION`, a gemspec, a
signature mirror and a smoke suite:

| Gem | Namespace | Runtime dependencies today |
|---|---|---|
Expand Down
12 changes: 7 additions & 5 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,14 @@ a different namespace and the probe does not confuse them.

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
domain model and phase 2's seam layer; 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 three pages written so far are
[`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the gate
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.
set is the thing phase 0 built, [`sdk-documentation/http.md`](./sdk-documentation/http.md),
because the domain model is the thing phase 1 built, and
[`sdk-documentation/seams.md`](./sdk-documentation/seams.md), because the seam layer is the thing
phase 2 built.

## Keeping this file true

Expand Down
6 changes: 3 additions & 3 deletions docs/first-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ 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 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.
(2026-09-14) `gems/` holds the six gems, since phase 1 (2026-09-15) `dexpace-core` carries the HTTP domain
model, and since phase 2 (2026-09-15) the seam layer as well; there is still no tag, and no version beyond the
`0.0.0` every gem starts at.

## Gems, once they exist

Expand Down
Loading
Loading