Repository navigation
Phase 2: seam foundations — documentation and phase record - #46
Merged
Merged
Conversation
Dexpace::SeamError, ClosedError and CancelledError (SEAM-5, SEAM-15, SEAM-18); Dexpace::Closeable with the latched, ownership-aware close and Dexpace.close_quietly (SEAM-14, SEAM-25); Dexpace::Cancellation and Cancellation::Source, the cooperative token both transport seams take as their third argument (SEAM-13), composing by monotonic stamp rather than by subscription and returning a detachable Subscription from #on_cancel; and Dexpace::Hooks, the one notification loop every write side runs so a raising handler drops no other (design P2-14, P2-15). Every mutex here is held across a flag flip or a snapshot swap and nothing else. sig/dexpace/hooks.rbs exists because the strict core Steep target checks every lib/ file; the constant stays private.
Dexpace::Async::Settlement, Completer and Future (SEAM-16, SEAM-17, SEAM-30): the state lives on the Completer, the Future is a facade, the queue is a wake-up signal and never the value channel, #fulfil on a lost race closes the orphan it was handed, #value blocks on a Thread::Queue so a registered Fiber.scheduler sees the wait, and #then is the one combinator (design P2-11). The outcome is published, and the abort hooks stolen, in one snapshot swap, so a raising hook can never leave a waiter blocked. Dexpace/QualifiedCoreConstant, the sixth custom cop (design §9 addendum A1): a bare Thread, Queue, Mutex, SizedQueue, ConditionVariable or JSON inside Dexpace::Async or Dexpace::Serde rebinds to the adapter gem's constant once that gem is required, and core's own suite never requires it. Scoped to lib/dexpace/async/, lib/dexpace/serde/ and the seam module lib/dexpace/serde.rb itself.
Dexpace::Registry implements SEAM-5..SEAM-9's five branches once -- one frozen snapshot swapped under a mutex, unsynchronised reads, a single-flight claim that builds outside the lock and records its owning fiber so a re-entrant resolve raises instead of parking, a claim released in an ensure so a LoadError leaves the registry re-evaluable, and the version-skew guard phase 0 postponed here as the required core: keyword, compared by hand because Gem is undefined under --disable-gems (P2-7). Dexpace::Transport, Dexpace::AsyncTransport (P2-1) and Dexpace::Serde: each a duck type, a .conforms? predicate, an RBS interface and five delegations to its own registry; the zero-candidate error names no gem (SEAM-2). Dexpace::Bridge::AsyncOver and SyncOver, SEAM-18's two bridges (P2-13), both Closeable and owning nothing; AsyncOver checks the token before and after the send and refuses an async transport at the first send. Dexpace::Serde::Error, a class root, with the encode and decode subtypes (SEAM-23, P2-2). Dexpace::Operation with its construction-time placeholder checks and the hand-built SEAM-27 composition, which is a concatenation and not RFC 3986 reference resolution (P2-3). lib/dexpace.rb requires the twenty files in dependency order.
The runtime surface manifest of dexpace-core grows from 212 to 318 lines, exactly the public constants and methods the twenty files define; no private_constant appears. The four adapter manifests each lose the one namespace line -- Dexpace::Transport, Dexpace::Serde or Dexpace::Async -- that core now defines, and the gate test that had pinned the phase-0 shape (an adapter's manifest beginning at the shared namespace) now asserts the property its own comment states: the manifest begins inside the shared namespace and holds none of core's lines. The core smoke suite's constant list gains the seam layer.
Registry.accepts_positionals? counted :req, :opt and :rest and ignored
:keyreq, so ->(request, options, cancellation, must:) {} passed
Transport.conforms? and AsyncTransport.conforms?, was accepted by
install and register, and raised Ruby's ArgumentError (missing keyword)
from inside the seam at the first send -- failure at use where the
predicate exists to fail at registration, and a stdlib error where the
seam promises InvalidArgumentError. The design's stated predicate
("required count <= 3 and (a rest parameter is present or required +
optional >= 3)") omitted keywords and the implementation reproduced the
gap; one clause closes it. An optional keyword, a keyword rest and a
block parameter leave the three-positional call intact and stay
admitted. Verified on 3.2.11 and 4.0.6.
Operation#build_request now treats a nil header input as absent, as it
already treats a nil query input, rather than sending the header with
an empty value: a generated client passing an unset optional header as
nil must not emit `X-Trace:`. An empty String is a value and goes out.
registry.rbs spells ::Method::param_types with the leading ::, since
Dexpace::Method exists in the same namespace and the relative name
resolved to Ruby's Method only because the phase-1 class declares no
such alias -- the RBS twin of the hazard Dexpace/QualifiedCoreConstant
guards in lib/.
…base
Operation validated a template's brace balance and its placeholder /
projection agreement and never looked at the literal text between the
placeholders, so `Operation.build(method: :get, template: "/x?y")` --
a generator putting a literal query in the template -- and "/a b",
"/pets/ü", "/100%", "/a<b", "/x#f", "/[x]" all constructed and then
raised URI::InvalidComponentError from URI::Generic#path= inside
Composition.compose at the first #build_request: a stdlib error, outside
`rescue Dexpace::Error`, where the plan promises InvalidArgumentError
for a malformed template and SEAM-27 a context-bearing error for a
composition resolving to a malformed URL. Path VALUES were never at
risk (encode_component yields pchars, and the property test proves it);
only the literal was unguarded.
Validation.literal! now checks the template with its placeholders
removed against RFC 3986 `path` -- every character a pchar or "/",
every "%" opening a two-hex-digit escape, the grammar #path= enforces
at assembly -- at construction, naming the template. The brace check
moves into the same step so an unbalanced brace is still reported as
such. An already-encoded literal such as "/a%20b" is a path and passes;
"pets" and "" pass as before. The set is spelled out rather than
borrowed from URI: phase 1's URL is the one place core reaches URI.
The probe that found the literal found its sibling: a base with no
hierarchical part ("mailto:x@y", "urn:isbn:123") parses as absolute,
passes URL.parse! and the fragment check, and leaks URI::InvalidURIError
"path conflicts with opaque" from the same #path=. validated_base now
refuses it beside the fragment, naming the base. Verified identical on
3.2.11 and 4.0.6 before and after.
…onto it
Phase 1's URL.parse! reaches URI::Generic#query=, whose percent check is
/(%\H\H)/ -- a "%" followed by two NON-hex characters -- so a base query
ending in a bare "%" or in "%z" ("https://host/c?sig=100%",
"https://host/c?;~%", "https://host/c?a=%z") is accepted at parse time.
Composition.compose then appends "&limit=1", the same writer sees "%&l"
and a stdlib URI::InvalidURIError escapes #build_request: the leak class
the template-literal and opaque-base fix closed on the path side,
surviving on the query side, where SEAM-27 requires a composition
resolving to a malformed URL to be rejected with a context-bearing error.
Operation gains QUERY_LITERAL, RFC 3986 `query` (pchar / "/" / "?",
every "%" a two-hex escape), and validated_base checks the parsed base's
query against it before anything is appended, raising
Dexpace::InvalidArgumentError naming the base -- whether or not the
operation query is empty, so a base malformed on its own is refused
rather than composed into a malformed URL silently. RFC 3986 `query` is
strictly tighter than #query='s check and the operation query is
Query#encode's, so no append can complete a "%\H\H"; the path side's
grammar was re-derived the same way (the parser's `segment` set equals
#path='s ABS_PATH), and compose's comment now states why neither writer
is rescued. Measured on 3.2.11 and 4.0.6 with a 40,000-base fuzz over
nine base shapes and four operations: 29,738 bases accepted by
URL.parse!, 5,031 stdlib leaks before, 0 after, identical on both.
Registry#register and #install build their conflict messages after the
lock is released rather than inside it: both interpolate #inspect of two
user objects, and a factory or provider whose #inspect reached back into
the registry met ThreadError: recursive locking instead of the argument
error. #install's swap moves into a private swap_in that reports the
conflicting incumbent and the handed-out flag out of the block, so the
synchronize body is the snapshot swap and nothing else, as the file's
own rule states; the sig/ mirror follows.
Twenty-four suites under gems/dexpace-core/test/ -- one mirror per lib/ file but the private_constant hooks.rb, plus the scheduler-transparency and constant-shadowing proofs, the version-skew grid against Gem::Requirement over six running versions, the SEAM-27 composition suite with its property test, and the seam-surface suite -- each header naming the IDs it exercises. Eight test-support files: the three in-memory fakes the roadmap's constraint 4 asks for, their companions one class per file, the probe Fiber.scheduler and the block-scoped WarningCapture (design P2-12). The cop's nine rejected and eight accepted cases, in a nested class of their own. Every concurrency guard the plan's verification table lists was run red by reverting its fix, on 4.0.6, and restored; the checklist records what each said.
…bsent
Registry.callable? -- and through it Transport.conforms? and
AsyncTransport.conforms? -- admitted a callable with a required keyword
beside its three positionals: registration succeeded and the first send
raised Ruby's ArgumentError (missing keyword) from inside the seam, the
outcome the predicate exists to prevent. The refutes land in
registry_test.rb (whose callable? cases move into a nested Callable
class, under the 100-line cap), transport_test.rb and
async_transport_test.rb; an optional keyword, a keyword rest and a block
parameter stay admitted. Red before the fix:
"callable? refuses a required keyword and admits the optional keyword
shapes: Expected true to not be truthy."
Operation#build_request rendered a nil header input as an empty header
(X-Trace: "") where a nil query input contributes nothing; the new case
in operation_build_request_test.rb takes the query side's reading and
says why. Red before the fix: "Expected #<data Dexpace::Headers
values={"x-trace" => [""]} ...> to not include "X-Trace"."
future_shadowing_test.rb's header now names SEAM-16 and SEAM-17, the
IDs it exercises under the adapter namespace.
…are refused
operation_test.rb gains the negative case beside "an unbalanced brace
and an empty placeholder are refused": thirteen literals -- "/x?y",
"/x#f", "/a b", "/pets/ü", "/100%", "/%2", "/%G1", "/a<b", "/[x]", a
quote, a tab, a newline and "/{id}?q" -- each refused at construction
with Dexpace::InvalidArgumentError (asserted to be a Dexpace::Error)
naming the template, and the positive case: every pchar, a slash, an
already-encoded octet, a rootless "pets", "" and a bare "{id}" all
construct. Red before the fix: "/x?y". Dexpace::InvalidArgumentError
expected but nothing was raised.
operation_build_request_test.rb gains the opaque base -- "mailto:x@y",
"urn:isbn:123" -- refused with a context-bearing error naming the base.
Red before the fix: [Dexpace::InvalidArgumentError] exception expected,
not Class: <URI::InvalidURIError> Message: <"path conflicts with
opaque">. And, in a third nested class under the 100-line cap, the
literal's twin of the path-value property: over 200 sampled templates
drawn from pchars, "/", "?", "#", "%", "{", "}", a space and "ü",
either .build refuses the template as the SDK's error or #build_request
composes a URL that re-parses to itself; only InvalidArgumentError is
rescued, so a stdlib URI error from the composition errors the test.
Red before the fix: URI::InvalidComponentError: bad component(expected
absolute path component): /c/F/ #ü. 36 of the 200 compose.
operation_build_request_test.rb gains the negative case beside the opaque base: "https://host/c?sig=100%", "https://host/c?;~%", "https://host/c?a=%z" and "https://host/c?a=[1]" -- each accepted by URL.parse!, whose URI::Generic#query= check is /(%\H\H)/ -- refused by build_request with Dexpace::InvalidArgumentError (asserted to be a Dexpace::Error) naming the base and the query, with and without an operation query appended; "https://host/c?sig=100%25" composes. A fourth class, Bases, holds the base-varying twin of the Templates property: 200 random queries over an alphabet of "a", "/", "?", "#", "%", "2", "F", "&", "=", ";", "~", "[", "]", " " and "ü", each either refused as the SDK's argument error or composed into a URL that re-parses to itself, with a non-empty operation query so the append that reaches URI::Generic#query= runs on every sample. Red before the fix: [Dexpace::InvalidArgumentError] exception expected, not Class: <URI::InvalidURIError> Message: <"invalid percent escape: %&l"> from operation.rb:318 Composition#compose, and the property errors with the same URI::InvalidURIError.
… outside the lock registry_test.rb's Reentrancy class gains two cases: a registration conflict and an install conflict whose rejected object's #inspect registers a second key on the same registry -- taking its write lock -- each raising Dexpace::InvalidArgumentError with the message naming the object, and the second registration landing. Red with the fix reverted, on both: [Dexpace::InvalidArgumentError] exception expected, not Class: <ThreadError> Message: <"deadlock; recursive locking"> from registry.rb:120 Registry#register (the message interpolated under @Write) and from registry.rb:144 via conflict! inside Registry#install's synchronize block.
The thirty-row checklist, written from what was built, with the twenty-one guard runs recorded and twenty-five departures from the plan's text itemised; three as-built notes beneath the design's Deviation Ledger (P2-9, P2-12, P2-15); the roadmap's dated status note; CLAUDE.md, the READMEs, docs/first-release.md and docs/sdk-documentation/quality-gates.md brought to the built tree; and docs/sdk-documentation/seams.md, the as-built page for the seam layer.
docs/sdk-documentation/seams.md said a resolution that completed inside a #swap block survives the block; it does not. Only the in-flight claim and the factories are taken from the live state; the resolved provider is restored, so a build that completes inside the block is discarded and closed and the next resolve builds again. The page now says so, states the transport predicate's required-keyword rule, records that Serde.conforms? is presence-only beside the transport seams' admitted gap, and states the three nil-input readings of Operation#build_request together. Dexpace/QualifiedCoreConstant is the seventh custom cop in the tree, not the sixth: phase 1 added Dexpace/NoKeywordSplat after the phase-2 design was written. CLAUDE.md and the roadmap's status note now count seven, the checklist says once why the design's addendum says six, and the design's as-built notes record it beside the predicate refinement. CLAUDE.md's "forty-two files under lib/dexpace/" is literally forty-three; the sentence now names phase 0's version.rb beside the forty-two phase-1 and phase-2 files it was counting. The checklist gains the review round's two red-then-green guards, deviations 26 and 27, the Callable class in registry_test.rb, and the run counts of the rebuilt tree.
…ase rule The checklist's status roll-up said "22 ✅ … 22 + 3 + 3 + 1 = 30, recounted from the table"; the table has 23 ✅ rows (SEAM-1, 2, 5, 6, 7, 8, 9, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 25, 26, 27, 29, 30 -- the three qualified ones included, none subtracted) and the stated sum was 29. Both the sentence and the roadmap status note's "thirty rows, 22 ✅" now say 23. The note's "forty-two files" takes CLAUDE.md's wording -- forty-two phase-1 and phase-2 files beside phase 0's version.rb -- since the directory holds 43. The round-2 fix is recorded where the phase's as-built record lives: the SEAM-27 row names the two places a stdlib URI error could escape the composition and how each is closed; deviation 28 carries the argument; the guards-run-red section carries the three red messages; the design's "as built" section gains the composition rule beside the predicate; the seams page states the template-literal and opaque-base refusals with the composition's other rules. Gate counts follow the tests branch: test:gems 525 runs / 3265 assertions, 99.93% (1512/1513) on 4.0.6, 525 runs on 3.2.11.
…ount the roll-up The checklist gains deviations 29 (a base URL whose query is not RFC 3986 is refused before composition, with the argument and the 40,000-base measurement behind the 'cannot raise' claim deviation 28 stated without either) and 30 (the registry's two conflict messages are built outside the lock), the round-3 red-run table, the SEAM-27 row's third closed leak and the thread-safety audit row's corrected list. The design's as-built notes gain the same two entries; docs/sdk-documentation/seams.md states the query rule beside the fragment and opaque ones and adds #inspect to what never runs under the registry lock; the roadmap's status note counts thirty departures, the number the checklist's list has carried since round 2 added its twenty-eighth.
test:gems is 529 runs / 3376 assertions at 99.93% line coverage (1521/1522 on 4.0.6, 1515/1516 on 3.2.11) with the four guards review round 3 added; deviation 25 and the 3.2.11 sentence follow.
This was referenced Sep 15, 2026
Contributor
Author
Review record for the phase 2 stack (#44 → #45 → #46)Four independent reviews, each by a fresh agent with no memory of the previous one, each re-running the gates itself; three fix rounds between them. Every finding got a stable id, a severity, a file:line and the command output that was its evidence.
No finding was ever blocking; nothing was skipped. Round 0 → fixed in round 1
Round 1 → fixed in round 2
Round 2 → fixed in round 3
Round 3 (final) — open, carried into the PR bodies
What the final reviewer verified, in its own runs
Run: workflow |
Wahbeh-Mohammad
changed the base branch from
9-phase-2-seam-foundations-tests
to
main
September 15, 2026 15:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #9. Third of three phase 2 PRs — the documentation and the phase record for #44 and #45. Targets #45's branch.
What lands
docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md, written from the build: 30 rows (SEAM-1–SEAM-30) — 23 ✅ (two with a named gap,SEAM-15andSEAM-25;SEAM-29satisfied in phase 1), 3 ⏳ (SEAM-12,SEAM-24,SEAM-28→ phase 5c Task 4), 3 🚫 (SEAM-3,SEAM-4— the byte-stream provider seam retired per design §10.1, its behavioural contract phase 3's — andSEAM-22's reflective mechanism, replaced by the witness protocol of §10.14), 1 N/A (SEAM-10, vacuous in Ruby, the version-skew guard built in its place). Plus what was built, the twelve guards run red with their messages, the audit groups run, thirty deviations from the plan's text, the findings routed, and the postponed work re-checked (close_quietly's two routes andHooks.notify's dropped failures → phase 4b Task 2; thedeadline:keyword → 5a Task 8;SEAM-25's lifecycle event → 8b and 9).Dexpace::Hooks), withRegistryrecorded as public API (P2-10) and the bridges'Dexpace::Bridgenamespace (P2-13).docs/sdk-documentation/seams.md— new as-built page for the seam layer, every example verified against the built code;architecture.md,quality-gates.md(seven cops now), the core README,README.md,docs/README.md,docs/first-release.mdupdated.CLAUDE.mdcount sentences re-derived from the tree (42 phase-1/2 files besideversion.rb, 43 sig mirrors, seven cops, a 318-line manifest); the roadmap gains the phase 2 status note.Verification
bundle exec rakeon Ruby 4.0.6 at this tip: exit 0, 191 s, all seventeen gates green (529 / 3,376 tests, 99.93%, YARD 0 undocumented);test:gemson 3.2.11: 529 runs, 100.00%.ruby .claude/skills/housekeeping/probe.rb: no drift; housekeeping suite 108 runs and knowledge suite 92 runs green;verify_knowledge_structure.rbOK.Known follow-up (not blocking)
Three commit subjects on the repair commits run to 80–86 characters against the repository's 74; squash-merge titles are the PRs'.