diff --git a/docs/deviations.md b/docs/deviations.md index a35b782..f2fa1ef 100644 --- a/docs/deviations.md +++ b/docs/deviations.md @@ -17,29 +17,60 @@ each names the amendment below that carries the narrowing), *contradicted* (none bridges survive -- is confirmed too (`gems/dexpace-core/lib/dexpace/resilience/`, `Dexpace::Bridge::SyncOver` and `AsyncOver`), and is phase 10's checklist row `RETRY-28`. +**Re-audited 2026-09-25, when a human applied the amendment set and consolidated the phase ledgers into +§10.** Every entry was checked again against as-built source (`gems/**/lib`, `sig/`, the suites), never against the +phase documents. Of the nineteen, **seven held exactly as written** (3, 7, 8, 11, 14, 16 and 19); **five held and +gained a dated clause** carrying a narrower as-built fact a phase ledger recorded (1, 2, 4, 9 and 15); and **seven +were corrected in place**, each with a dated note quoting what it replaced — 5 (its "only entries" sentence, and +`C12`'s attribution), 6 (`C14`), 10 (`C19`), 12 (`C3`), 13 (`C18`), 17 (whose last clause described a close path that +does not exist) and 18 (`C15`). The chapter's opening claim — two unmet MUSTs and nothing else narrowed — was +corrected too. **Nineteen entries were added, 20–38**, one per group of +genuine deviations the phase ledgers recorded and §10 did not, three of them MUST clauses unmet on a stated domain +(20–22). No row's verdict is *contradicted*: where a sentence misdescribed the code, the chapter was wrong and the +code right, and no live defect was found. The rows below were re-derived on that date; rows 20–38 are new. + ## The audit | # | Title | IDs touched | Status | |---|---|---|---| | 1 | The byte-stream provider seam is retired; its behavioural contract is not | SEAM-3–SEAM-10, IO-1–IO-42, XCUT-23 | as-built: confirmed, narrower -- the apparatus is absent (`gems/dexpace-core/lib/dexpace/io.rb` defines `Dexpace::IO::Buffer`, `BufferedSource`, `BufferedSink`, `TeeSink` and no registration entry point; the only registries are `Dexpace::Transport::REGISTRY`, `Dexpace::AsyncTransport::REGISTRY` and `Dexpace::Serde::REGISTRY`) and the contract is 3a's `gems/dexpace-core/lib/dexpace/io/`; narrower where XCUT-23's third surviving instance is the async transport seam, not an executor (C17) | -| 2 | The canonical body is a duck type, not a nominal interface | SEAM-3, BODY-1, BODY-35 | as-built: confirmed -- `Dexpace::IO::BufferedSource.over` (`gems/dexpace-core/lib/dexpace/io/buffered_source.rb:91`) takes no ownership, measured: closing the source leaves the upstream open, where `.wrapping` (`:48`) closes it; every body yields BINARY chunks from `#each` (`gems/dexpace-core/lib/dexpace/http/body.rb`) | +| 2 | The canonical body is a duck type, not a nominal interface | SEAM-3, BODY-1, BODY-35, HTTP-36 | as-built: confirmed (re-audited 2026-09-25; the entry now carries 3b's `P3-15`: the body slot is typed `Dexpace::Body?` in `gems/dexpace-core/sig/dexpace/http/request.rbs`) -- `Dexpace::IO::BufferedSource.over` (`gems/dexpace-core/lib/dexpace/io/buffered_source.rb:91`) takes no ownership, measured: closing the source leaves the upstream open, where `.wrapping` (`:48`) closes it; every body yields BINARY chunks from `#each` (`gems/dexpace-core/lib/dexpace/http/body.rb`) | | 3 | The async pivot is a core-owned future rather than an ecosystem primitive | SEAM-1, SEAM-16, SEAM-17, ASYNC-1, ASYNC-2, NFR-11 | as-built: confirmed -- the pivot is `Dexpace::Async::Future`, `Dexpace::Async::Completer` and `Dexpace::Async::Settlement` (`gems/dexpace-core/lib/dexpace/async/future.rb`), core-owned; `gates:rbs_surface` finds no foreign async type in any shipped signature | -| 4 | Cancellation is cooperative; the orphaned-response close moves to the producer | SEAM-13, SEAM-30, XCUT-1–XCUT-3, CFG-17, CFG-20, CFG-21, RETRY-23, TRANSPORT-3, ASYNC-5 | as-built: confirmed, narrower -- `Dexpace::Async::Completer#fulfil` closes an undelivered result (`gems/dexpace-core/lib/dexpace/async/completer.rb:96`); the socket-closing hooks are the transports' (`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/response_pump.rb:120`, `gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/exchange.rb:134`) and the pool owns no socket, so the mitigation names a transport, not an adapter (C12) | +| 4 | Cancellation is cooperative; the orphaned-response close moves to the producer | SEAM-13, SEAM-30, XCUT-1–XCUT-3, CFG-17, CFG-20, CFG-21, RETRY-23, TRANSPORT-3, ASYNC-5, ASYNC-4, SEAM-18, RECOV-11 | as-built: confirmed, narrower (re-audited 2026-09-25; the entry now carries 2's `P2-4` and 4b's `P4-17`: `Dexpace::Bridge::SyncOver` raises `Dexpace::CancelledError`, and no RECOV-11 re-assert exists because the token is a latch) -- `Dexpace::Async::Completer#fulfil` closes an undelivered result (`gems/dexpace-core/lib/dexpace/async/completer.rb:96`); the socket-closing hooks are the transports' (`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/response_pump.rb:120`, `gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/exchange.rb:134`) and the pool owns no socket, so the mitigation names a transport, not an adapter (C12, applied to entry 5 on 2026-09-25) | | 5 | Two MUSTs are not satisfied, and a third holds vacuously | ASYNC-3, ASYNC-4, PIPE-33 | as-built: confirmed as admitted, not re-opened -- `ASYNC-3` waived by ID as would-fail in `gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb`, `PIPE-33`'s interrupt clause unmet, `ASYNC-4` vacuous; the gap is `docs/first-release.md` § Unsatisfied MUSTs | -| 6 | Suppressed exceptions are a core-owned trail, not a host facility | RECOV-12, PAGE-13, PAGE-15, SSE-29, SSE-30, SSE-36, RETRY-34, XCUT-9 | as-built: confirmed, narrower -- the trail is `Dexpace::Suppressible` (`gems/dexpace-core/lib/dexpace/suppressible.rb:46`), included by `Dexpace::Error` and extended onto any exception by `Dexpace.attach_suppressed`, not a method of `Dexpace::Error` itself (C14); one cycle-safe walk, `Dexpace.each_cause` over `compare_by_identity` (`gems/dexpace-core/lib/dexpace/each_cause.rb:48`), kept alone by `gates:cause_walk` | +| 6 | Suppressed exceptions are a core-owned trail, not a host facility | RECOV-12, PAGE-13, PAGE-15, SSE-29, SSE-30, SSE-36, RETRY-34, XCUT-9 | as-built: confirmed, narrower (re-audited 2026-09-25: `C14` applied, and the entry now carries 4b's `P4-13` -- on a frozen primary `Dexpace.attach_suppressed` is a documented no-op) -- the trail is `Dexpace::Suppressible` (`gems/dexpace-core/lib/dexpace/suppressible.rb:46`), included by `Dexpace::Error` and extended onto any exception by `Dexpace.attach_suppressed`, not a method of `Dexpace::Error` itself (C14); one cycle-safe walk, `Dexpace.each_cause` over `compare_by_identity` (`gems/dexpace-core/lib/dexpace/each_cause.rb:48`), kept alone by `gates:cause_walk` | | 7 | "Standard library" is narrowed to what is stable across the supported Ruby range | SEAM-1, NFR-1, AUTH-14, OBS-2 | as-built: confirmed -- `gems/dexpace-core/dexpace-core.gemspec` declares no dependency; Basic is `pack("m0")` (`gems/dexpace-core/lib/dexpace/auth/basic_handler.rb:45`); no `require "logger"` in core; `gates:require_allowlist` and `gates:clean_bundle` green on every row | | 8 | Discovery's substrate is require-time self-registration, not classpath scanning | SEAM-5–SEAM-9, XCUT-23 | as-built: confirmed, narrower -- adapters self-register at require time (`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http.rb`) into `Dexpace::Registry`'s five branches (`gems/dexpace-core/lib/dexpace/registry.rb`); the permitted instrumentation auto-activation exists nowhere (`gems/dexpace-core/test/dexpace/seam_surface_test.rb`), so the restriction holds by absence | -| 9 | SEAM-10's multi-loader de-duplication is vacuous and is replaced by a version-skew guard | SEAM-10 | as-built: confirmed -- vacuous with a replacement: `Dexpace::Registry#register(key, factory, core:)` (`gems/dexpace-core/lib/dexpace/registry.rb:123`) is the version-skew guard; `gates:single_instance` green | -| 10 | Runtime encapsulation of models is partially unachievable | HTTP-2/SEAM-29, HTTP-4, HTTP-7, HTTP-17, HTTP-18, IO-28/BODY-37, XCUT-18 | as-built: confirmed, narrower -- `Dexpace::Request.send(:new, ...)` bypasses `.build` but not validation: it runs the validating `#initialize` (`gems/dexpace-core/lib/dexpace/http/request.rb:51`), so a GET with a body raises HTTP-7 and a non-`Headers` headers value is refused (measured on 4.0.6 and 3.2.11); the residual holes are `Dexpace::Request.allocate`, which yields a Request whose four members are nil, and duck typing, and the mitigation for both is that both transports re-validate header names and outbound values at the wire (`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/request_mapper.rb:58`, `gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/request_mapper.rb:50`) (C19) | +| 9 | SEAM-10's multi-loader de-duplication is vacuous and is replaced by a version-skew guard | SEAM-10, SEAM-18 | as-built: confirmed (re-audited 2026-09-25; the entry now carries 8b's `P8-21`: `Dexpace::Async::Thread::REQUIRED_CORE` is checked directly at require time in `gems/dexpace-async-thread/lib/dexpace/async/thread.rb`, there being no executor registry) -- vacuous with a replacement: `Dexpace::Registry#register(key, factory, core:)` (`gems/dexpace-core/lib/dexpace/registry.rb:123`) is the version-skew guard; `gates:single_instance` green | +| 10 | Runtime encapsulation of models is partially unachievable | HTTP-2/SEAM-29, HTTP-4, HTTP-7, HTTP-17, HTTP-18, IO-28/BODY-37, XCUT-18 | as-built: confirmed, narrower -- `Dexpace::Request.send(:new, ...)` bypasses `.build` but not validation: it runs the validating `#initialize` (`gems/dexpace-core/lib/dexpace/http/request.rb:51`), so a GET with a body raises HTTP-7 and a non-`Headers` headers value is refused (measured on 4.0.6 and 3.2.11, and re-measured 2026-09-25); the residual holes are `Dexpace::Request.allocate`, which yields a Request whose four members are nil, and duck typing, and the mitigation for both is that both transports re-validate header names and outbound values at the wire (`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/request_mapper.rb:58`, `gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/request_mapper.rb:50`) (C19, applied 2026-09-25) | | 11 | Read-only collection exposure is computed once, not wrapped per access | HTTP-5, XCUT-15 | as-built: confirmed -- `Dexpace::Model.own` (`gems/dexpace-core/lib/dexpace/model.rb`) deep-freezes a copy it owns; a returned header value list refuses mutation (measured); phase 10 repaired its default-proc escape | -| 12 | One stream-ownership rule for bodies, resolving a reference inconsistency | BODY-8, SEAM-3, SEAM-20, SEAM-21 | as-built: confirmed, narrower -- a body closes only what it opened (`gems/dexpace-core/lib/dexpace/http/body/stream_body.rb`), ownership-on-wrap is `Dexpace::IO::BufferedSource.wrapping`'s, a codec closes nothing (`gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`); §10.12 attributes the I/O rule to SEAM-3 where the live ID is IO-6 (C3) | -| 13 | The serde seam ships four encode profiles, two of which are one Ruby type | SEAM-20 | as-built: confirmed, narrower -- all four profiles ship (`gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb:145`), `dump_string` tagged UTF-8 and `dump_bytes` ASCII-8BIT (measured); each takes the value first, which §10.13 does not spell (C18) | +| 12 | One stream-ownership rule for bodies, resolving a reference inconsistency | BODY-8, SEAM-3, SEAM-20, SEAM-21, IO-6 | as-built: confirmed, narrower -- a body closes only what it opened (`gems/dexpace-core/lib/dexpace/http/body/stream_body.rb`), ownership-on-wrap is `Dexpace::IO::BufferedSource.wrapping`'s, a codec closes nothing (`gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`); §10.12 attributed the I/O rule to SEAM-3 where the live ID is IO-6 (C3, applied 2026-09-25) | +| 13 | The serde seam ships four encode profiles, two of which are one Ruby type | SEAM-20, SERDE-4 | as-built: confirmed, narrower -- all four profiles ship (`gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb:145`), `dump_string` tagged UTF-8 and `dump_bytes` ASCII-8BIT (measured); each takes the value first, which §10.13 did not spell (C18, applied 2026-09-25); narrower again by 7a's `P7-5`, now in the entry -- the buffer profile takes a mutable BINARY `String` only | | 14 | The serde witness is a class-object-and-combinator protocol, not a reflective type token | SEAM-22, SEAM-23, SERDE-5–SERDE-8, SERDE-16, SERDE-17 | as-built: confirmed -- `Dexpace::Serde.witness!` over `Dexpace::Serde::WITNESS_METHOD` (`gems/dexpace-core/lib/dexpace/serde/witness.rb:17`); the hierarchy is `Dexpace::Serde::Error` with `Dexpace::Serde::SerializationError` and `Dexpace::Serde::DeserializationError` under it; no compile-time refusal, as admitted | | 15 | The cross-origin redirect marker lives on the per-hop cursor, not on the request | REDIR-11, AUTH-29, PIPE-16 | as-built: confirmed -- `cursor.fork(state: { cross_origin: ... })` on every drive (`gems/dexpace-core/lib/dexpace/redirect/step.rb:166`), read only through `cursor.state` (`gems/dexpace-core/lib/dexpace/auth/step.rb:118`); `Dexpace::Pipeline::Cursor` has no state setter | | 16 | The configuration chain keeps four tiers with a substituted third source | CFG-1, CFG-3, CFG-4, CFG-24, CFG-26, OBS-35 | as-built: confirmed -- `Dexpace::Configuration#string` (`gems/dexpace-core/lib/dexpace/configuration.rb:115`) reads override, environment, property, default; the property tier is `Dexpace.configure` (`gems/dexpace-core/lib/dexpace/config.rb:43`); `Dexpace::Proxy.resolve` reads the same chain; phase 10 bounded the chain a repeated configure grew | -| 17 | The interruptible sleep is a cancellable queue wait, not `Kernel#sleep` | CFG-15, CFG-17, CFG-18, RETRY-26, XCUT-3, XCUT-13 | as-built: confirmed -- `Dexpace::Clock#sleep` is a per-call queue pop woken by the token (`gems/dexpace-core/lib/dexpace/clock.rb:113`); phase 10 refused NaN and Complex in its guard and in `Dexpace::Async.delay` | -| 18 | Platform-constant substitutions where Ruby has no constant | IO-9, BODY-32, SSE-11, RECOV-34 | as-built: confirmed, narrower -- `Dexpace::IO::MAX_MATERIALIZED_BYTES` (`gems/dexpace-core/lib/dexpace/io.rb:27`), `Dexpace::SSE::MAX_RETRY_MS` (`gems/dexpace-core/lib/dexpace/sse.rb:58`), `Dexpace::Resilience::Policy::MAX_DURATION_NANOSECONDS` (`gems/dexpace-core/lib/dexpace/resilience/policy.rb:77`); 7b added `Dexpace::SSE::MAX_LINE_BYTES` and `Dexpace::SSE::MAX_EVENT_BYTES`, which the entry does not list (C15) | +| 17 | The interruptible sleep is a cancellable queue wait, not `Kernel#sleep` | CFG-15, CFG-17, CFG-18, RETRY-26, XCUT-3, XCUT-13, ASYNC-15 | as-built: confirmed, narrower (corrected 2026-09-25: the entry's last clause, that the queue wait "lets §3.7's graceful close wait without parking a cancelled caller", described no code -- `Dexpace::Async::Thread::Pool#close` takes no token and drains within `shutdown_timeout`, `gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb`, 8b's `P8-24`; the clause is replaced in place, `C21`) -- `Dexpace::Clock#sleep` is a per-call queue pop woken by the token (`gems/dexpace-core/lib/dexpace/clock.rb:113`); phase 10 refused NaN and Complex in its guard and in `Dexpace::Async.delay` | +| 18 | Platform-constant substitutions where Ruby has no constant | IO-9, BODY-32, SSE-11, RECOV-34, SSE-19, RETRY-16, RETRY-18 | as-built: confirmed, narrower -- `Dexpace::IO::MAX_MATERIALIZED_BYTES` (`gems/dexpace-core/lib/dexpace/io.rb:27`), `Dexpace::SSE::MAX_RETRY_MS` (`gems/dexpace-core/lib/dexpace/sse.rb:58`), `Dexpace::Resilience::Policy::MAX_DURATION_NANOSECONDS` (`gems/dexpace-core/lib/dexpace/resilience/policy.rb:77`); 7b added `Dexpace::SSE::MAX_LINE_BYTES` and `Dexpace::SSE::MAX_EVENT_BYTES`, which the entry did not list (C15, applied 2026-09-25); the entry now also carries 5a's `P5-56` (the ceiling is read per call through `Dexpace::IO.max_materialized_bytes`), 3a's `P3-4` and 6a's `P6-61` (the pacing parser's fifteen-digit, 64-byte bound, `gems/dexpace-core/lib/dexpace/resilience/pacing_parsers.rb`) | | 19 | The dead-code-survival gate is retargeted, not deleted | NFR-8, NFR-9 | as-built: confirmed -- vacuous by its own text; retargeted at the require-allowlist audit and the clean-bundle run (`gems/dexpace-core/dexpace-core.gemspec`, `tasks/gates.rake`), both green on every row | +| 20 | SERDE-27's no-materialisation clause is not satisfied by the shipped JSON codec | IO-9, SERDE-12, SERDE-27 | as-built: confirmed as admitted, added 2026-09-25 from 7a's `P7-1` -- `Dexpace::Serde::JSON::Codec#load` drains the source into one String under `Dexpace::IO.max_materialized_bytes` (`gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`), while `Dexpace::Serde::DecodingHandler` hands it the unread source (`gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb`); also `docs/first-release.md` § Unsatisfied MUSTs | +| 21 | TRANSPORT-14's header-name clause is unreachable on the asynchronous transport | TRANSPORT-14, TRANSPORT-27 | as-built: confirmed as admitted, added 2026-09-25 from 8c's `P8-38` -- `WAIVED = %w[TRANSPORT-14 TRANSPORT-27]` with the measured "unreachable here" test in `gems/dexpace-transport-async_http/test/dexpace/transport/async_http/conformance_test.rb`; the failure is a retryable `Dexpace::TransportError`; the TRANSPORT-27 half had no ledger row | +| 22 | HTTP-45's no-pinning clause holds for threads, not for a second fiber of one thread with no scheduler | BODY-22, HTTP-45 | as-built: confirmed as admitted, added 2026-09-25 from 3b's `P3-27` and `P3-28` -- `Dexpace::TypedResponse#value` (`gems/dexpace-core/lib/dexpace/http/typed_response.rb`) and `Dexpace::ResponseLoggingBody`'s drain (`gems/dexpace-core/lib/dexpace/http/body/response_logging_body.rb`) wait on a `Thread::ConditionVariable`, which parks the carrier thread when no `Fiber.scheduler` is installed | +| 23 | The orchestrator's "every throwable" is every StandardError | PIPE-30, RECOV-2, RECOV-8 | as-built: confirmed, added 2026-09-25 from 4b's `P4-19` and 4c's `P4-52` -- `Dexpace::Recovery::Orchestrator#call` re-raises `Dexpace::OutcomeError` and converts `::StandardError` alone (`gems/dexpace-core/lib/dexpace/recovery/orchestrator.rb`) | +| 24 | Retryable throwables are classified by capability, not by I/O type | RECOV-17, RETRY-2, XCUT-4, XCUT-6 | as-built: confirmed, added 2026-09-25 from 6a's `P6-4` -- `Dexpace::Resilience::Policy.throwable_retryable?` asks `#retryable?` over `Dexpace.each_cause` (`gems/dexpace-core/lib/dexpace/resilience/policy.rb`); both transports wrap in `Dexpace::TransportError` | +| 25 | The asynchronous transport runs only inside a reactor, and its response does not outlive that reactor | TRANSPORT-21, TRANSPORT-25 | as-built: confirmed, added 2026-09-25 from 8c's `P8-39` and `P8-94` -- with no `Async::Task` current the adapter fails the future with `Dexpace::SeamError` (`REACTOR_MESSAGE`, `gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/adapter.rb`) | +| 26 | A borrowed Net::HTTP is used verbatim: one origin, serialised calls, no per-call timeout | TRANSPORT-15, TRANSPORT-19, TRANSPORT-5, XCUT-22 | as-built: confirmed, added 2026-09-25 from 8a's `P8-6`, `P8-15` and `P8-53` -- the borrowing call refuses `options.timeout` and a foreign origin and takes a one-permit `SizedQueue` (`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/adapter.rb`, `Dexpace::Transport::NetHTTP`) | +| 27 | ASYNC-18's delay blocks no caller and no worker, but one timer thread per pool | ASYNC-18 | as-built: confirmed, added 2026-09-25 from 8b's `P8-25` -- `Dexpace::Async::Thread::Pool#delay` schedules onto one lazily spawned timer thread whose `#cancel` removes the entry (`gems/dexpace-async-thread/lib/dexpace/async/thread/timer.rb`) | +| 28 | CFG-18's delay settles with true and refuses to block | CFG-18, SEAM-16 | as-built: confirmed, added 2026-09-25 from 5a's `P5-9` and `P5-52` -- `Dexpace::Async.delay` settles with `ELAPSED = true` and raises `Dexpace::SeamError` with no scheduler (`gems/dexpace-core/lib/dexpace/async/delay.rb`) | +| 29 | Two clauses are vacuous by a false antecedent: CFG-19's unwrap and PAGE-15's wrapping | CFG-19, NFR-4, PAGE-15 | as-built: confirmed as vacuous, added 2026-09-25 from 5a's `P5-11` and 7c's `P7-1` -- no unwrap exists and `Dexpace::Async::Completer` delivers the caller's own object (`gems/dexpace-core/lib/dexpace/async/completer.rb`); a page close failure is raised as itself (`gems/dexpace-core/lib/dexpace/page/closing.rb`); design §12 lists both (`C13`) | +| 30 | Where OBS-11's userinfo redaction meets OBS-16's "returned verbatim", OBS-11 wins | HTTP-19, OBS-11, OBS-14, OBS-16 | as-built: confirmed, added 2026-09-25 from 5b's `P5-100` and `P5-107` -- `Dexpace::Instrumentation::Redactor` scrubs every `//`-authority's userinfo on its surgery route (`gems/dexpace-core/lib/dexpace/instrumentation/redactor.rb`) | +| 31 | The diagnostic context restores, snapshots and folds with three stated narrowings | OBS-10, OBS-23, OBS-24, XCUT-20 | as-built: confirmed, added 2026-09-25 from 5c's `P5-49`, `P5-72` and 5b's `P5-22`, `P5-39`, `P5-97` -- `Dexpace::Instrumentation::Diagnostics` compacts and shallow-freezes its snapshot and skips `RESERVED_PREFIX` keys (`gems/dexpace-core/lib/dexpace/instrumentation/diagnostics.rb`); the restore is `Fiber[k] = prior` in `gems/dexpace-core/lib/dexpace/instrumentation/scope.rb` | +| 32 | Trace identity follows the flavour, and the no-op tracer is shared across operations | CTX-15, OBS-25, OBS-26, OBS-27, OBS-29 | as-built: confirmed, added 2026-09-25 from 4a's `P4-7` and 5c's `P5-43` -- `Dexpace::Instrumentation::TraceIdFlavour` carries its own invalid id (`gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb`); `Dexpace::Instrumentation::NO_TRACER_FACTORY` answers `Dexpace::Instrumentation::NO_TRACER` every call | +| 33 | A redirect predicate overrides the follow decision but not the hop cap, and an explicit default port is not kept | AUTH-29, REDIR-13, REDIR-17, REDIR-18, REDIR-19, REDIR-20, REDIR-21, REDIR-23, REDIR-8 | as-built: confirmed, added 2026-09-25 from 6b's `P6-91` and `P6-96` -- `Dexpace::Redirect::Step#plan` checks the cap after the predicate (`gems/dexpace-core/lib/dexpace/redirect/step.rb`); the default-port residue is pinned in `gems/dexpace-core/test/dexpace/redirect/step_test.rb` | +| 34 | Four authentication clauses yield to the port's other rules | AUTH-11, AUTH-16, AUTH-22, AUTH-23, AUTH-31, AUTH-36, AUTH-37, HTTP-18 | as-built: confirmed, added 2026-09-25 from 6c's `P6-2`, `P6-5`, `P6-7` and `P6-76` -- `Dexpace::Auth::AsyncBearerStamper`, `Dexpace::Auth::ChallengeHandlerChain`, `Dexpace::Auth::Step` and `Dexpace::Auth::DigestHandler` (`gems/dexpace-core/lib/dexpace/auth/`) | +| 35 | Two pagination clauses are met by stated readings | PAGE-10, PAGE-16, PAGE-19 | as-built: confirmed, added 2026-09-25 from 7c's `P7-5`, `P7-117` and `P7-6` -- `Dexpace::Page.next_request_from` ends on a raw same-document target (`gems/dexpace-core/lib/dexpace/page.rb`); `Dexpace::Page::CursorStrategy` calls its extractor once per page (`gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb`) | +| 36 | SERDE-24's round-trip holds on a stated precision domain | SERDE-24 | as-built: confirmed, added 2026-09-25 from 7a's `P7-8` -- `Dexpace::Serde::Instant` encodes with `#iso8601(6)` (`gems/dexpace-core/lib/dexpace/serde/instant.rb`), both halves pinned in `gems/dexpace-core/test/dexpace/serde/instant_test.rb` | +| 37 | Three model clauses take a Ruby shape | BODY-9, HTTP-1, HTTP-34, HTTP-38, HTTP-9 | as-built: confirmed, added 2026-09-25 from 1's `P1-10`, `P1-14` and 3b's `P3-16` -- `Dexpace::Method::IDEMPOTENT` (`gems/dexpace-core/lib/dexpace/http/method.rb`), `Dexpace::RequestOptions`' String-only tags (`gems/dexpace-core/lib/dexpace/http/request_options.rb`), `Dexpace::StreamBody`'s `pos`/`seek` probe (`gems/dexpace-core/lib/dexpace/http/body/stream_body.rb`) | +| 38 | Two defects are detected as far as Ruby lets them be | BODY-11, BODY-17, PIPE-15, PIPE-5 | as-built: confirmed as admitted, added 2026-09-25 from 4c's `P4-33`, `P4-37` and 3b's `P3-21`, `P3-28` -- `Dexpace::Pipeline::Cursor`'s unsynchronised single-use latch (`gems/dexpace-core/lib/dexpace/pipeline/cursor.rb`), one `Dexpace::PipelineError` class, and `Dexpace::FileBody`'s documented abandoned-`#each` residue (`gems/dexpace-core/lib/dexpace/http/body/file_body.rb`) | Numbering follows §10's own list order and is not renumbered as entries are confirmed built; a row's number is a citation, the same as an item ID in `docs/first-release.md`, the one other register @@ -144,6 +175,20 @@ cannot transcribe -- **the consolidation of every phase's as-built Deviation Led and whose alternative form is that the release notes name the sentences, and the ledgers, a reader should not trust. After that blocker is answered this section is a record, not a holding area. +**Applied 2026-09-25, by a human, and the blocker answered.** Each of `C1`-`C19` was re-verified against as-built +source before it was applied, and each was found correct; every one is applied in place in the chapter it names +with a dated, bracketed note that quotes the sentence it replaced and cites the as-built file, so no frozen +sentence was silently overwritten. Two were narrowed while applying: `C19`'s "every model's `#initialize` +validates" holds for every HTTP domain model but not for `Dexpace::Pipeline::Entry`, whose validation lives in +`.build` alone (said in §4's note), and `C8`'s closure was re-measured against the bundle this tree resolves +(`net-http` 0.9.1, `async` 2.46.0, `async-http` 0.105.0, `io-event` 1.22.1), with the same counts. `C11` is applied +to appendix C's two rows as a dated note in the text cell, in the recommendation idiom the amendment names, and +touches no chapter of the specification. The reconciliation found six more sentences of the same kind — the rule +right, the mechanism sentence wrong about the shipped code — and applied them the same way, as `C20`-`C25` below. +The consolidation half is done too: design §10 now carries entries 20-38, and the audit table above has a row for +each. **This section is now a record, not a holding area**: the dated notes below are the measured history of each +amendment, and a new finding against a frozen chapter lands here only until the next deliberate amendment. + | # | File and sentence | Replacement | Code half, verified 2026-09-25 | |---|---|---|---| | C1 | §3.1 (`03-…md:78`): "applies the media type's charset via `String#encode(invalid: :replace, undef: :replace)`" | "retags the BINARY bytes to the media type's charset -- UTF-8 when it is absent or unknown -- and then transcodes with both encodings named, `#encode(target, invalid: :replace, undef: :replace)`" | `Dexpace::Response#body_string` (`gems/dexpace-core/lib/dexpace/http/response.rb`): `MediaType#charset`, `#read_string(encoding)`, then a named-target `#encode` | @@ -165,6 +210,12 @@ should not trust. After that blocker is answered this section is a record, not a | C17 | §11.8 (`11-…md:32`): XCUT-23's three instances "(transport, serde, executor)" | "(transport, async transport, serde)" -- there is no executor registry | `Dexpace::AsyncTransport::REGISTRY` (`gems/dexpace-core/lib/dexpace/async_transport.rb:22`) | | C18 | §10 item 13 (`10-…md:93`): "`#dump_to(sink)` and `#dump_into(buffer, offset:)`" | "`#dump_to(value, sink)` and `#dump_into(value, buffer, offset: 0)`" (and `#dump_string(value)`, `#dump_bytes(value)`) | `gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb:145-200` | | C19 | §4 (`04-…md:45`): "`Req.send(:new, ...)` reaches the generated constructor anyway"; §10 item 10 (`10-…md:78-79`): "`private_class_method :new` is bypassable by `send` by design" | "`send(:new, ...)` bypasses `.build` but not validation -- every model's `#initialize` override validates and calls `super`, so it re-runs HTTP-4, HTTP-7 and the member type checks; what remains open is `.allocate`, which yields an instance whose members are all nil, and duck typing" | `Dexpace::Request#initialize` (`gems/dexpace-core/lib/dexpace/http/request.rb:51`); `Request.send(:new, ...)` with a GET and a body raises HTTP-7, and `Request.allocate` returns all-nil members, measured on 4.0.6 and 3.2.11 (phase 10's review round 0, R0-6) | +| C20 | §10's opening claim ("With two named exceptions ... none narrows a MUST-level correctness guarantee"; "Nothing new appears here"), §10.5's "the only entries in this catalogue that are not mechanism substitutions", and §12's two list heads, its `TRANSPORT` row's `TRANSPORT-30` deferral and its MUST-level summary | a dated correction naming the three MUST clauses unmet on a stated domain (§10.20-§10.22) and the four stated edges (§10.6, §10.26, §10.33, §10.34), and `TRANSPORT-30` as shipped | `gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`, `gems/dexpace-transport-async_http/test/dexpace/transport/async_http/conformance_test.rb` (`WAIVED`), `gems/dexpace-core/lib/dexpace/http/typed_response.rb`; `PROXY_LIMITATION_EVENT` in `gems/dexpace-transport-net_http/lib/dexpace/transport/net_http.rb` (applied 2026-09-25) | +| C21 | §10 item 17's closing clause, "is what lets §3.7's graceful close wait without parking a cancelled caller", and §3.7's "the wait itself performed through §8.3's cancellable queue wait and a bounded deadline" | the close is bounded by the pool's construction-time `shutdown_timeout` and takes no token; no close path calls `Clock#sleep` | `Dexpace::Async::Thread::Pool#close` (`gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb`, 8b's `P8-24`) (applied 2026-09-25) | +| C22 | §3.4: "the round-trip **SERDE-24** requires holds by construction" | add "for every `Time` whose sub-second part is a whole number of microseconds" | `Dexpace::Serde::Instant` truncates through `#iso8601(6)` (`gems/dexpace-core/lib/dexpace/serde/instant.rb`, 7a's `P7-8`) (applied 2026-09-25) | +| C23 | §3.4's "`JSON` itself is stateless and the configuration is a frozen options hash", §7.3's `SERDE-26` sentence, §11.18's "`JSON` is stateless" and §12's `SERDE` row -- all written against json 2.9.1 | each codec instance owns a private `::JSON::Coder` built from options, and no caller engine is accepted, so SERDE-26 is satisfied literally | `gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb` (7a's `P7-4`) (applied 2026-09-25) | +| C24 | §7.2: the SSE machine runs "over §3.1's line-reading primitive" | it runs over its own byte-level reader, because §3.1's primitive keeps a lone CR as content under IO-14 | `Dexpace::SSE::LineReader` over `#getbyte` (`gems/dexpace-core/lib/dexpace/sse/line_reader.rb`, 7b's `P7-20`) (applied 2026-09-25) | +| C25 | §9's `NFR-10` row: `required_ruby_version = ">= 3.2"` "in every gemspec" | every gemspec but `dexpace-transport-async_http`'s, which isolates `async-http`'s `>= 3.3` floor as NFR-10's own isolated unit | `VERSIONS`' `ruby floor:dexpace-transport-async_http 3.3` row and `gems/dexpace-transport-async_http/dexpace-transport-async_http.gemspec` (8c's `P8-36`) (applied 2026-09-25) | **2026-09-08 — against design §3.1's decode recipe. Established by phase 3b's design.** The frozen sentence fixes one decode boundary as `Response#body_string`, "which applies the media type's charset via @@ -331,5 +382,6 @@ A deviation discovered by a review or an audit, with no phase in flight to recor no standing permission to edit §10 directly, lands here first: dated, with the IDs it touches and what was found. It is folded into `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md` the next time §10 is deliberately amended by a human — this section is a holding area, not a -permanent second ledger. See `docs/README.md`'s "Frozen means frozen" for why routine work does +permanent second ledger. Every note above was folded on 2026-09-25, when `C1`-`C25` were applied; the notes stay +as the record of what each amendment measured. See `docs/README.md`'s "Frozen means frozen" for why routine work does not edit §10 directly. diff --git a/docs/first-release.md b/docs/first-release.md index 99edfcc..b234577 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -189,7 +189,7 @@ stated in the release notes rather than discovered at `bundle install`. `#promote_to_request` and `#promote_to_exchange`, and where the operation name goes -- because no phase builds a call path that does it and this is the one place a generated client's author meets it (`CTX-16`, `CTX-14`, `SEAM-28`; the *Behavioural asymmetries* entry below) -- [ ] **The eighteen recorded corrections (fourteen until phase 10 completed the set on 2026-09-25) to `docs/sdk-design-ruby/` §3, §4, §5, §8, §9, §10, §11, §12 and to +- [x] **The eighteen recorded corrections (fourteen until phase 10 completed the set on 2026-09-25) to `docs/sdk-design-ruby/` §3, §4, §5, §8, §9, §10, §11, §12 and to appendix C's `SSE-19` row applied — or the release notes stating which design sentences a reader should not trust.** Filed 2026-09-13 by phase 10's design. Every one is a place where the **rule** is right, the **mechanism sentence** is wrong about Ruby or about a library, and the phase that @@ -243,7 +243,16 @@ stated in the release notes rather than discovered at `bundle install`. `P10-21`-`P10-38` -- into design §10.** Each phase's status note ends "consolidation into design §10 is a human's"; phase 10 is the last phase and the chapter is frozen to it. **The alternative form holds for this half too**: a release may ship with the ledgers unconsolidated if its notes list the phase - ledgers a reader must consult beside §10 + ledgers a reader must consult beside §10. **Ticked 2026-09-25, both halves, by a human.** `C1`-`C19` + were each re-verified against as-built source and applied in place, with a dated, bracketed note quoting + the sentence replaced and citing its code half — `C11` as a recommendation note on appendix C's two rows, + the only edit to `docs/product-spec/`; six more of the same kind the reconciliation found, `C20`-`C25`, + were applied the same way and are written out in `docs/deviations.md`'s amendment set. The consolidation + is done: every phase's ledger rows were triaged against the reference contract, the genuine deviations + §10 did not carry became entries 20-38 (or dated clauses on entries 1, 2, 4, 6, 9, 13, 15, 17 and 18), + and the rows that record only a departure from the design or the plan, with the specification met, were + excluded. `gates:ledger_audit` holds the 38 rows of `docs/deviations.md` to the 38 entries. No release + note needs to list a sentence a reader should not trust on this account - [ ] An RBS sig-diff baseline established, so a later release can be checked against it for an accidental breaking change. **Phase 10 is the phase that can**: its plan, Task 5 is the last change to `sig/` in every gem, so Task 18 establishes the baseline over a tree nothing else will move — @@ -414,6 +423,21 @@ find it in a design document. it reads is the configured `Dexpace::IO.max_materialized_bytes`. Cites `SERDE-27`, `SEAM-21`, `IO-9`, `BODY-32`. +- **Two MUST clauses unmet on a stated domain, recorded in §10 on 2026-09-25 when the phase ledgers were + consolidated.** Neither is a new finding — each was a phase ledger row — but neither had reached this + section. **`HTTP-45`'s no-pinning clause (and `BODY-22`'s once-drain)** holds for threads and not for a + second fiber of the same thread with no `Fiber.scheduler` installed: `Thread::Mutex` and + `Thread::ConditionVariable` defer to an installed scheduler and otherwise park the carrier thread, so such a + fiber arriving mid-parse blocks the thread, and deadlocks if the parsing fiber is never resumed (3b's + `P3-27`, design §10 entry 22, `gems/dexpace-core/lib/dexpace/http/typed_response.rb`). Ruby has no + scheduler-independent primitive that serialises without parking; the pick-up trigger is a consumer that + drives one response's typed value from two fibers of one thread without a scheduler. **`TRANSPORT-14`'s + header-name clause (with `TRANSPORT-27`'s SHOULD `Content-Length` half)** is unreachable on + `dexpace-transport-async_http`, where `protocol-http1` refuses the head out of the read, so the whole + response fails as a retryable `Dexpace::TransportError` (8c's `P8-38`, design §10 entry 21); the waiver and + its disclosure are the conformance entry above. Its trigger is an `async-http`/`protocol-http1` release + that tolerates such a header. + ### SHOULD- and MAY-level requirements declined for v1 Each bullet: the IDs with their level, why v1 declines them, the trigger that would reopen the decision, diff --git a/docs/product-spec/appendix-c-consolidated-normative-requirement-index.md b/docs/product-spec/appendix-c-consolidated-normative-requirement-index.md index fa9d85a..38dfdd4 100644 --- a/docs/product-spec/appendix-c-consolidated-normative-requirement-index.md +++ b/docs/product-spec/appendix-c-consolidated-normative-requirement-index.md @@ -425,7 +425,7 @@ Every normative requirement defined in this specification, aggregated for confor | SSE-16 | MUST | Server-Sent Events and streaming | The reader MUST be single-pass and stateful: only the 'BOM already consumed' flag persists across next() calls; the last-event-id is NOT carried forward between events — each event surfaces only the id present in its own block. A reimplementation MUST NOT maintain a WHATWG-style persistent last-event-id buffer inside the parser. | | SSE-17 | MUST | Server-Sent Events and streaming | The reader MUST NOT own or close the underlying byte source; source lifecycle is the caller's responsibility. (Resource ownership is introduced only by the SseStream facade — see SSE-23.) | | SSE-18 | MUST | Server-Sent Events and streaming | A single reader instance MUST be driven from one thread at a time; the parser is not internally synchronized and offers no thread-safety for concurrent next() calls. A port MAY leave the parser non-thread-safe. | -| SSE-19 | MAY | Server-Sent Events and streaming | The parser MAY accept arbitrarily long lines / data values with no built-in size cap; the reference implementation imposes no maximum line or event size (a growable byte accumulator expands by doubling). | +| SSE-19 | MAY | Server-Sent Events and streaming | The parser MAY accept arbitrarily long lines / data values with no built-in size cap; the reference implementation imposes no maximum line or event size (a growable byte accumulator expands by doubling). [Amended 2026-09-25, amendment C11 of docs/deviations.md, as a recommendation in the idiom of the Ruby design's §11: this row omits the port sanction its chapter grants — 13-server-sent-events-and-streaming.md ends SSE-19 with "a port MAY add a configurable cap and reject/truncate oversized lines, documenting the divergence" — and the chapter says "lines/values" where this row says "line or event". The two statements together are the complete SSE-19. The Ruby port's two rejecting caps, dexpace-core's SSE::MAX_LINE_BYTES and SSE::MAX_EVENT_BYTES, are recorded in its design §10 entry 18.] | | SSE-20 | MUST | Server-Sent Events and streaming | The parsed event value MUST be immutable and hold a defensively-copied, read-only data list so that neither the caller's originally-supplied list nor later mutations can reach inside a constructed event, and any copy-with-changes operation MUST likewise copy the data list. | | SSE-21 | SHOULD | Server-Sent Events and streaming | The event value SHOULD provide structural value semantics — equality and hash derived from all five fields (id, event, data, comment, retry), and a stable string form — so events can be compared and used in collections. | | SSE-22 | SHOULD | Server-Sent Events and streaming | The event value SHOULD expose an 'is-empty' predicate that is true only when all five fields are unset/empty; because a comment counts as content, a comment-only (keep-alive) event SHOULD report non-empty. | @@ -506,7 +506,7 @@ Every normative requirement defined in this specification, aggregated for confor | OBS-26 | MUST | Instrumentation and observability | An instrumentation/trace context MUST expose W3C-Trace-Context-compliant identifiers: a trace id, a span id (16 lowercase hex chars), trace flags (a two-hex-char byte, e.g. the sampled bit), and a vendor trace-state list. It MUST expose validity and remoteness flags. The reserved invalid sentinels MUST be: trace id of 32 hex zeros, span id of 16 hex zeros, trace flags '00', and empty trace-state; an all-zero trace/span id MUST be treated as invalid/no-trace. | | OBS-27 | MUST | Instrumentation and observability | Trace-id generation MUST support at least the W3C flavour (128-bit value rendered as 32 lowercase hex chars) and a Datadog flavour (64-bit unsigned integer rendered as a decimal string), plus a no-op flavour that always yields the invalid sentinel. Generation MUST NOT produce the reserved all-zero id — a zero draw MUST be coerced to a non-zero value. | | OBS-28 | SHOULD | Instrumentation and observability | The SDK SHOULD provide an HTTP-shaped tracer event vocabulary richer than start/end: operation started/succeeded/failed; per-attempt started/failed(with next-delay)/retries-exhausted; and transport milestones (request URL resolved, connection acquired with host+port, request sent with byte count, response headers received with status+headers, response received with byte count). Every event method SHOULD default to a no-op so adding a new event is a non-breaking change and implementers override only what they need. | -| OBS-29 | MUST | Instrumentation and observability | HTTP-tracer lifecycle ordering MUST hold: operationStarted fires once at the start; operationSucceeded and operationFailed are mutually exclusive and each fires exactly once at the end; attempt events may fire multiple times; retries-exhausted (when it fires) is immediately followed by operationFailed with the same throwable. One tracer instance corresponds 1:1 to a single logical operation lifecycle (created by the factory per operation). This is a documented emission contract; pipeline/transport wiring to emit it is a follow-up, so it is not yet runtime-enforced. | +| OBS-29 | MUST | Instrumentation and observability | HTTP-tracer lifecycle ordering MUST hold: operationStarted fires once at the start; operationSucceeded and operationFailed are mutually exclusive and each fires exactly once at the end; attempt events may fire multiple times; retries-exhausted (when it fires) is immediately followed by operationFailed with the same throwable. One tracer instance corresponds 1:1 to a single logical operation lifecycle (created by the factory per operation). This is a documented emission contract; pipeline/transport wiring to emit it is a follow-up, so it is not yet runtime-enforced. [Amended 2026-09-25, amendment C11 of docs/deviations.md, as a recommendation in the idiom of the Ruby design's §11: the chapter statement, 15-instrumentation-and-observability.md, carries neither the "(created by the factory per operation)" parenthetical nor the follow-up clause above, and carries a Conformance clause this row drops — drive a succeeding and a failing (retry-exhausted) operation through a conformant emitter and assert the ordering and the exhausted-then-failed pairing. The two statements together are the complete OBS-29; read from the chapter alone, the emission wiring looks like an open obligation, which is how five Ruby-port documents came to carry it as an open decision.] | | OBS-30 | MUST | Instrumentation and observability | Tracer and HTTP-tracer callbacks MUST be safe to invoke concurrently and from transport threads other than the caller's, and implementations MUST NOT throw from any callback. The metrics instruments MUST likewise be safe to call concurrently from request threads and MUST NOT throw. The instrumentation runtime does not defensively catch these callbacks (see OBS-20), so violating must-not-throw breaks the caller's request. | | OBS-31 | MUST | Instrumentation and observability | The metrics SPI MUST expose a Meter that manufactures at least a monotonic integer counter and a floating-point histogram, each accepting per-measurement key/value attributes. The default Meter MUST be a no-op that discards every measurement and returns shared instrument singletons, and the core MUST NOT pull a metrics runtime into its dependencies. | | OBS-32 | SHOULD | Instrumentation and observability | Metric instrument names, descriptions, and units SHOULD follow OpenTelemetry semantic conventions and UCUM unit symbols. The default HTTP instrumentation SHOULD emit a request counter named 'http.client.request.count' (unit '{request}') and a latency histogram named 'http.client.request.duration' (unit 'ms'), tagged with method and either status code (success) or error type (failure). | diff --git a/docs/sdk-design-ruby.md b/docs/sdk-design-ruby.md index dfdc7a2..be3dd9f 100644 --- a/docs/sdk-design-ruby.md +++ b/docs/sdk-design-ruby.md @@ -19,6 +19,12 @@ layer; it specifies only the runtime primitives a generator would target (§3.5) protocol beyond HTTP/1.1 for the reference transport, nor does it design the service-client surface a generator would emit on top of it. +**Reconciled 2026-09-25 against the as-built tree.** The chapters below were written before the code and are kept +as written, except where the shipped code proved a sentence wrong: each such sentence is corrected in place with a +dated, bracketed note quoting what it replaced and citing its amendment in `docs/deviations.md` (`C1`–`C25`), and +§10 — the deviation ledger — gains entries 20–38, consolidating every deviation from the reference contract the +phases' own ledgers recorded and the chapter did not. §12 is brought into line with both. + --- ## Table of Contents diff --git a/docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md b/docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md index dce7ef7..40b9426 100644 --- a/docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md +++ b/docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md @@ -75,12 +75,23 @@ always downstream of something that already owns the response. **Encoding, stated once.** Bytes read from or written to a socket are always `Encoding::BINARY` (verified the same object as `ASCII_8BIT`); `String#b` is the idiom for "these bytes, untagged"; `force_encoding` is a *retag* used only where the bytes are known to conform; and there is exactly one decode boundary, `Response#body_string`, which -applies the media type's charset via `String#encode(invalid: :replace, undef: :replace)` and falls back to UTF-8 -when absent or unknown (**HTTP-42**). The port never trusts a transport's tagging — `Net::HTTP` returns bodies +retags the BINARY bytes to the media type's charset -- UTF-8 when it is absent or unknown -- and then transcodes +with both encodings named, `#encode(target, invalid: :replace, undef: :replace)` (**HTTP-42**). [Amended +2026-09-25, `C1` of `docs/deviations.md`: this sentence read "applies the media type's charset via +`String#encode(invalid: :replace, undef: :replace)` and falls back to UTF-8 when absent or unknown". Over bytes +already retagged BINARY that recipe replaces every byte at or above `0x80`, and a target-less `#encode` follows the +host's `Encoding.default_internal`. As built, `Dexpace::Response#body_string` +(`gems/dexpace-core/lib/dexpace/http/response.rb:113`) resolves the charset through `MediaType#charset`, retags with +`#read_string(encoding)` and transcodes with the target named.] The port never trusts a transport's tagging — `Net::HTTP` returns bodies tagged `ASCII-8BIT` regardless of the declared charset — and always retags to BINARY on ingress. **Two ownership rules, deliberately different.** At the I/O layer, wrapping takes ownership: closing a -`BufferedSource` built over a caller's `IO` closes that `IO` (**SEAM-3**). At the body layer the rule is inverted +`BufferedSource` built over a caller's `IO` closes that `IO` (**IO-6**). [Amended 2026-09-25, `C3` of +`docs/deviations.md`: this citation read **SEAM-3**, the ID §10 item 1 retires with the provider seam; the live +statement of ownership-on-wrap is **IO-6**, whose only normative text is appendix C's row. As built, +`Dexpace::IO::BufferedSource.wrapping` (`gems/dexpace-core/lib/dexpace/io/buffered_source.rb:48`) constructs with +`owns_upstream: true` and closing it closes the wrapped stream, asserted under the `IO-6` heading of +`gems/dexpace-core/test/dexpace/io/buffered_source_test.rb`.] At the body layer the rule is inverted and uniform: **a body closes exactly the sources it opened itself** — a file-backed body opens and closes a fresh handle per write (**BODY-11**), a body over a caller-supplied `IO` or `Enumerator` never closes it, and transfer is opted into explicitly at the factory. Conflating the two is the trap **BODY-8** names ("A port MUST decide its @@ -165,15 +176,31 @@ transport, a `Dexpace::Pipeline` can stand in wherever a transport is expected ( declare conformance it structurally already has (P14). Core ships a `Dexpace::Transport` module with a `.conforms?` predicate and no implementation, satisfying **SEAM-2**'s "core never names a concrete implementation." -Streaming is preserved end to end: `dexpace-transport-net_http` issues the request inside -`Net::HTTP#request(req) { |res| ... }` and exposes the response body as a `BufferedSource` over the block-scoped -`Net::HTTPResponse#read_body` stream, so **SEAM-11**'s no-pre-buffering clause and **TRANSPORT-25**'s "lazily-read -stream, not pre-buffered ... closing the SDK response cascades to close the native body and release the connection" -are satisfied literally. The reference transport disables nothing for **TRANSPORT-1**/**TRANSPORT-2** because -`Net::HTTP` follows no redirects and retries nothing on its own — those two requirements are vacuous for this -adapter and load-bearing for a future Faraday-stack or `httpx` adapter, which is exactly the per-transport scoping -the specification's own §17 preamble anticipates ("Where a behavior exists in only one reference transport ... the -requirement is scoped accordingly"). +Streaming is preserved end to end: `dexpace-transport-net_http` runs the whole `Net::HTTP` exchange on a +per-response producer `Thread` over a `Thread::SizedQueue(1)`, whose `read_body` block pushes each chunk, and the +caller drains it through a `#readpartial`-shaped reader that a `BufferedSource` owns; that keeps the body lazy and +closable, so **SEAM-11**'s no-pre-buffering clause and **TRANSPORT-25**'s "lazily-read stream, not pre-buffered ... +closing the SDK response cascades to close the native body and release the connection" are satisfied. [Amended +2026-09-25, `C5` of `docs/deviations.md`: this sentence read that the adapter issues the request inside +`Net::HTTP#request(req) { |res| ... }` and exposes the body over the block-scoped `read_body` stream, satisfying +both clauses "literally". Measured on `net-http` 0.6.0 under 3.4.10, the block form with a block that does not read +returns with the body already buffered and the socket nilled, and a later `read_body` raises +`IOError: ... read_body called twice`; a `Fiber` in the thread's place is refused by `FiberError: fiber called +across threads`. As built, `Dexpace::Transport::NetHTTP::ResponsePump` +(`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/response_pump.rb`) is that producer and reader +(phase 8a's `P8-1`).] `Net::HTTP` follows no redirects, so **TRANSPORT-1** is vacuous for this adapter; but +`Net::HTTP#max_retries` defaults to 1 and re-sends an idempotent request on its own, so the adapter sets it to 0 +on every client it builds and refuses a borrowed client whose value is not 0, and **TRANSPORT-2** is satisfied by +construction rather than vacuous. Both stay load-bearing for a future Faraday-stack or `httpx` adapter, which is +exactly the per-transport scoping the specification's own §17 preamble anticipates ("Where a behavior exists in only +one reference transport ... the requirement is scoped accordingly"). [Amended 2026-09-25, `C6` of +`docs/deviations.md`: this sentence read "The reference transport disables nothing for **TRANSPORT-1**/**TRANSPORT-2** +because `Net::HTTP` follows no redirects and retries nothing on its own — those two requirements are vacuous for this +adapter". Measured on `net-http` 0.6.0, `#transport_request` retries GET, HEAD, PUT, DELETE, OPTIONS and TRACE on +nine error classes including `IOError`, and at the default a call whose connection another thread closed returned +`200`. As built, `http.max_retries = 0` in `Dexpace::Transport::NetHTTP::Adapter` +(`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/adapter.rb:154`), and `.using` asserts it at +`:38`.] Per-call options (**SEAM-11**, **TRANSPORT-5**, **ASYNC-19**) are threaded as an immutable `Dexpace::RequestOptions` value and applied to a *per-call* `Net::HTTP` instance's `open_timeout`, `read_timeout` @@ -341,11 +368,18 @@ explicit witness (§7.3); there is no witness-less overload to fall into. Two adapter defaults are fixed here rather than left to the JSON gem's own. The adapter's default encoder configuration renders date and time values as ISO-8601 strings, never epoch numbers, and the corresponding witness parses that form back to the same instant, so the round-trip **SERDE-24** requires holds by construction rather -than by the caller matching two independent conventions. And `Dexpace::Serde::JSON.default` is a *factory*: every +than by the caller matching two independent conventions — for every `Time` whose sub-second part is a whole number +of microseconds. [Amended 2026-09-25, `C22` of `docs/deviations.md`: "by construction" was unqualified. As built, +`Dexpace::Serde::Instant` (`gems/dexpace-core/lib/dexpace/serde/instant.rb`) encodes through `Time#iso8601(6)`, +which truncates, so a float-derived `0.123456` second encodes as `.123455`; every `Time` the SDK constructs or +decodes lies inside the domain (phase 7a's `P7-8`, §10 entry 36).] And `Dexpace::Serde::JSON.default` is a *factory*: every call returns a fresh, independently configured adapter instance rather than a shared one, so an application that reconfigures the codec it was handed cannot change the codec another part of the process is using (**SERDE-25**). -Both are cheap here — `JSON` itself is stateless and the configuration is a frozen options hash (§7.3) — which is -precisely why there is no reason to skip them. **SERDE-23**'s tolerant decode of unknown fields is the witness's +Both are cheap here — each codec instance owns one private `::JSON::Coder` built from its own options (§7.3) — +which is precisely why there is no reason to skip them. [Amended 2026-09-25, `C23` of `docs/deviations.md`: this +read "`JSON` itself is stateless and the configuration is a frozen options hash", which described json 2.9.1; the +2.19.9 floor ships `JSON::Coder`, and `Dexpace::Serde::JSON::Codec` +(`gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`) builds one per instance (phase 7a's `P7-4`).] **SERDE-23**'s tolerant decode of unknown fields is the witness's default: a witness reads the keys it declares and ignores the rest, so backward-compatible server additions do not break a deployed client, and strictness is opt-in per witness. @@ -494,7 +528,12 @@ real constraint: `dexpace-async-thread`'s close signals its queue and returns, i `Kernel#sleep` or an unbounded `Thread#join`. **ASYNC-16**'s graceful shutdown — stop accepting work, let in-flight tasks finish — is a **SHOULD** and is what the adapter does by default, with the wait itself performed through §8.3's cancellable queue wait and a bounded deadline, so a caller who closes inside a cancelled scope is not -parked. **SEAM-15** is a **MAY** and the port takes it explicitly: **a send after close raises +parked. [Amended 2026-09-25, `C21` of `docs/deviations.md`: the wait is bounded, not cancellable. As built, +`Dexpace::Async::Thread::Pool#close` takes no arguments — `Dexpace.close_quietly` calls it with none — and drains +within its construction-time `shutdown_timeout` +(`gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb`); no close path waits through `Clock#sleep`, so a +caller in a cancelled scope waits at most that budget (phase 8b's `P8-24`, §10 entry 17).] **SEAM-15** is a +**MAY** and the port takes it explicitly: **a send after close raises `Dexpace::ClosedError`**, documented rather than left undefined, because "undefined" in Ruby means whatever `NoMethodError` the internals happen to produce. diff --git a/docs/sdk-design-ruby/04-domain-model-construction.md b/docs/sdk-design-ruby/04-domain-model-construction.md index cc58fd0..169d54d 100644 --- a/docs/sdk-design-ruby/04-domain-model-construction.md +++ b/docs/sdk-design-ruby/04-domain-model-construction.md @@ -19,7 +19,11 @@ models from "value types with no builder (media type, status, typed header name, [which] derive via re-construction through factories." The port follows that split exactly: `MediaType`, `Status`, `Protocol`, `Method`, `HeaderName` and the conditional-request helpers are `Data` types with `parse`/`of` factories and `#with`; `Request`, `Response`, `Headers`, `Query`, `RequestOptions` and `Configuration` get real -mutable `Builder` classes, because their validation is cross-field (**HTTP-7** rejects a body on +mutable `Builder` classes, and so does the multipart body (`MultipartBody#new_builder`, `MultipartBody::Builder`), +which **HTTP-3** names in its builder-based list [Amended 2026-09-25, `C2` of `docs/deviations.md`: the list +omitted the multipart body. As built, `Dexpace::MultipartBody::Builder` +(`gems/dexpace-core/lib/dexpace/http/body/multipart_body.rb`) is pre-filled by `#new_builder`, which `dup`s the +parts list, and `gems/dexpace-core/test/dexpace/http/body/multipart_body_test.rb` asserts the non-aliasing], because their validation is cross-field (**HTTP-7** rejects a body on GET/HEAD/TRACE/CONNECT; **HTTP-8** defaults the method to GET only when there is no body) and cannot be expressed as per-member checks. `#new_builder` returns a builder pre-filled from the instance that `dup`s every collection rather than aliasing it (**HTTP-3**), so later builder mutation cannot reach back into the source model. @@ -42,8 +46,17 @@ without Ractor being a load-bearing mechanism (§9's runtime floor keeps it out **The encapsulation gap, stated honestly (P8).** Ruby cannot close **HTTP-2**/**SEAM-29**'s "no public field-wise constructor" the way a language with enforced constructor privacy can, and there are two independent holes. *(i) Construction privacy is advisory.* `private_class_method :new` on a `Data` subclass works, and a validating -`.build` funnels all legitimate construction — but verified: `Req.send(:new, ...)` reaches the generated -constructor anyway, because `send` bypassing `private` is a deliberate, documented Ruby feature, not an oversight. +`.build` funnels all legitimate construction — but `send` bypassing `private` is a deliberate, documented Ruby +feature, not an oversight, so `Req.send(:new, ...)` bypasses `.build`. It does not bypass validation: every HTTP domain model's +`#initialize` override validates and calls `super`, so a `send(:new, ...)` re-runs **HTTP-4**, **HTTP-7** and the +member type checks. What remains open is `.allocate`, which yields an instance whose members are all `nil`. +[Amended 2026-09-25, `C19` of `docs/deviations.md`: this sentence read "verified: `Req.send(:new, ...)` reaches the +generated constructor anyway". As built, `Dexpace::Request#initialize` +(`gems/dexpace-core/lib/dexpace/http/request.rb:51`) validates before `super`; `Request.send(:new, ...)` with a GET +and a body raises the HTTP-7 `InvalidArgumentError`, and `Request.allocate` answers `nil` for all four members, +measured on 4.0.6 and 3.2.11. Re-derived 2026-09-25 over every class that includes `Dexpace::Model`: all but +one define their own `#initialize`; the exception is `Dexpace::Pipeline::Entry`, a pipeline value and not an +**HTTP-2** domain model, whose validation lives in `.build` alone.] *(ii) Duck typing admits impersonation.* Any object responding to `#method`, `#url`, `#headers` and `#body` satisfies every structural expectation a pipeline step has of a `Request`, entirely bypassing builder validation. Neither hole can be closed. The mitigation is threefold and is deliberately not a fake proof: the official diff --git a/docs/sdk-design-ruby/07-pagination-sse-and-serialization.md b/docs/sdk-design-ruby/07-pagination-sse-and-serialization.md index 89fd414..04dd401 100644 --- a/docs/sdk-design-ruby/07-pagination-sse-and-serialization.md +++ b/docs/sdk-design-ruby/07-pagination-sse-and-serialization.md @@ -48,10 +48,13 @@ defaults to inline on the settling thread with an executor mode for blocking con ### 7.2 Server-Sent Events Ruby ships no SSE client, so there is no native implementation to argue against; the WHATWG line and field grammar -(**SSE-1**–**SSE-15**) is a small synchronous state machine over §3.1's line-reading primitive — which is exactly -why that primitive was hand-written rather than delegated to `IO#gets`, since **SSE-2** requires LF, CR and CRLF -all recognised with a lone CR terminating alone and **SSE-14** requires an unterminated final line returned as -content. **SSE-12**'s single leading BOM is consumed through a non-consuming lookahead (§3.1's peek), so a +(**SSE-1**–**SSE-15**) is a small synchronous state machine over its own byte-level line reader — hand-written +rather than delegated to `IO#gets`, since **SSE-2** requires LF, CR and CRLF all recognised with a lone CR +terminating alone and **SSE-14** requires an unterminated final line returned as content. [Amended 2026-09-25, `C24` +of `docs/deviations.md`: this read "over §3.1's line-reading primitive — which is exactly why that primitive was +hand-written". §3.1's primitive keeps a lone CR as content under **IO-14**, which **SSE-2** forbids, so as built +`Dexpace::SSE::LineReader` (`gems/dexpace-core/lib/dexpace/sse/line_reader.rb`) reads `#getbyte` and recognises the +three terminators itself with a one-byte pushback (phase 7b's `P7-20`).] **SSE-12**'s single leading BOM is consumed through a non-consuming lookahead (§3.1's peek), so a mid-stream BOM survives as data. **SSE-11**'s "The representable maximum is runtime-specific (signed 64-bit ms in the reference); a port MUST pick a documented cap and reject beyond it rather than wrap" is spec-sanctioned latitude, and it needs exercising because Ruby integers are arbitrary-precision and cannot overflow: the port @@ -164,10 +167,13 @@ array-element degradation is a three-line branch in the combinator. Three closing notes. **SERDE-21**/**SERDE-22**'s strict coercion policy is satisfied *by the codec doing nothing*: `JSON.parse` performs no coercion, so `"5"` never silently becomes `5`; the strictness burden moves into the witness, where each field asserts its expected class and raises a `DeserializationError` naming the target type on -mismatch — which is also how **SERDE-13** is enforced. **SERDE-26**'s "private copy of the codec engine" is close -to vacuous for a stateless `JSON` module and is satisfied by holding configuration in a frozen options hash owned -by the `Serde` instance; the requirement's own fallback clause covers this and the behaviour is documented rather -than silent. **SERDE-9**–**SERDE-12**'s failure model is a `Dexpace::Serde::Error` root with `SerializationError` +mismatch — which is also how **SERDE-13** is enforced. **SERDE-26**'s "private copy of the codec engine" is +satisfied literally: each codec instance owns a private `::JSON::Coder` built from options, and no caller-supplied +engine is ever accepted, so there is nothing of the caller's to mutate and the fallback clause is never invoked. +[Amended 2026-09-25, `C23` of `docs/deviations.md`: this read "close to vacuous for a stateless `JSON` module and is +satisfied by holding configuration in a frozen options hash … the requirement's own fallback clause covers this", +written against json 2.9.1, which has no `JSON::Coder`; as built at the 2.19.9 floor, +`Dexpace::Serde::JSON::Codec` (`gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`) is phase 7a's `P7-4`.] **SERDE-9**–**SERDE-12**'s failure model is a `Dexpace::Serde::Error` root with `SerializationError` and `DeserializationError` subtypes, adapters catching the backing library's exceptions and re-raising inside the `rescue` so Ruby sets `#cause` automatically, and a genuine stream `IOError` propagating unwrapped (**SERDE-12**) rather than being reclassified — which is also the failure half of **SEAM-20** and **SEAM-21** (§3.4), satisfied diff --git a/docs/sdk-design-ruby/08-instrumentation-and-configuration.md b/docs/sdk-design-ruby/08-instrumentation-and-configuration.md index 6018467..74fce90 100644 --- a/docs/sdk-design-ruby/08-instrumentation-and-configuration.md +++ b/docs/sdk-design-ruby/08-instrumentation-and-configuration.md @@ -28,10 +28,16 @@ on a `sink.info { }` call, and two SHOULDs hang off the same object. So core def ``` Dexpace::Instrumentation::Event # obtained from Logger#event(severity), never constructed directly - #field(key, value) #tag(key, value) #event(name) #cause(error) #emit + #field(key, value) #event(name) #cause(error) #emit Dexpace::Instrumentation::Event::INERT # the frozen shared singleton returned when the severity is disabled ``` +[Amended 2026-09-25, `C4` of `docs/deviations.md`: the block listed a fifth builder, `#tag(key, value)`, which no +requirement names — **OBS-4**'s one reserved tag is what `#event(name)` writes, and **OBS-5**'s precedence rule +enumerates exactly three contributing sources, so a fourth keyed channel would have no precedence and no collision +rule. As built, `Dexpace::Instrumentation::Event` (`gems/dexpace-core/lib/dexpace/instrumentation/event.rb`) and +`Event::INERT` define `#field`, `#event`, `#cause` and `#emit` and no `#tag` (phase 5b's `P5-18`).] + Every builder method returns `self` so chains compose, and `INERT` is a frozen instance whose builder methods return `self` and whose `#emit` does nothing. `Logger#event` performs the enabled check **once**, at that call, and returns either a fresh live event or `INERT` — so a disabled `logger.event(:verbose).field(:k, v).cause(e).emit` @@ -213,7 +219,18 @@ hazard the requirement targets. `Process.clock_gettime(Process::CLOCK_MONOTONIC) Both are behind the injectable seam so tests control them (**CFG-15**). **The prohibition, stated once and enforced by lint.** `Timeout.timeout`, `Thread#raise` and `Thread#kill` are -forbidden in every gem in this repository. `Timeout.timeout` schedules an asynchronous interrupt that can land on +forbidden in code this repository writes, which `Dexpace/NoThreadInterrupt` enforces over every gem's `lib/`. A +dependency may use them: `net-http`'s connect phase is `Timeout.timeout(@open_timeout, Net::OpenTimeout)` on every +supported Ruby, and the first such call starts a process-wide thread a host that counts threads sees once; the +interrupt can land only inside `TCPSocket.open`, before any SDK object holds a socket, and arrives as a typed +`Net::OpenTimeout`. The `async-http` closure calls no `Timeout.timeout`; it has seven `Fiber#raise` sites, each +resuming a fiber at a scheduler checkpoint, one `Thread.current.raise` (an ordinary raise on the calling thread in +`io-event`'s pure-Ruby selector), and one `Thread#kill` on a `Process::Status.wait` helper thread no SDK path +reaches. [Amended 2026-09-25, `C8` of `docs/deviations.md`: this sentence read "forbidden in every gem in this +repository", which a dependency's use falsified while the repository's own code honoured it. Re-measured +2026-09-25 against the bundle this tree resolves: `net/http.rb:1601` on 3.2.11 and 3.3.12, `:1657` on 3.4.10 and +`net-http` 0.9.1's `:1791`; `async` 2.46.0, `async-http` 0.105.0 and `io-event` 1.22.1 carry the sites counted +above. The cop and the ban stand as written.] `Timeout.timeout` schedules an asynchronous interrupt that can land on *any* bytecode instruction — including inside an `ensure` block that is releasing a pooled connection, or between a socket read and the bookkeeping that records it — which is precisely how a connection pool acquires a corrupt entry that fails a later, unrelated request. Deadlines are instead propagated as explicit values to diff --git a/docs/sdk-design-ruby/09-toolchain-and-quality-gates.md b/docs/sdk-design-ruby/09-toolchain-and-quality-gates.md index da5edbe..c49506b 100644 --- a/docs/sdk-design-ruby/09-toolchain-and-quality-gates.md +++ b/docs/sdk-design-ruby/09-toolchain-and-quality-gates.md @@ -15,14 +15,28 @@ set locally, so a gate cannot be an opt-in job nobody runs. | Concurrency-model agnosticism (**NFR-11**) | An RBS scan asserting that no constant outside `Dexpace::` and a fixed stdlib allowlist appears in any public signature under `sig/` — which is what mechanises "leaks no async-framework types into the core public surface" and is why the pivot of §3.3 had to be core-owned | | Single-instance guarantee (§2.4) | A test asserting `$LOADED_FEATURES` holds exactly one resolved path per core file and that `Dexpace::VERSION` matches the loaded gemspec, plus the registration-time version assertion each adapter runs — the auditable form of a claim §2.4 otherwise argues structurally | | Shrink-survival guard (**NFR-8**, **NFR-9**) | Retargeted at the require graph and stdlib drift (§9.2) | -| Runtime-floor discipline (**NFR-10**) | `required_ruby_version = ">= 3.2"` in every gemspec; CI matrix 3.2 / 3.3 / 3.4 / 4.0 running the real suite | +| Runtime-floor discipline (**NFR-10**) | `required_ruby_version = ">= 3.2"` in every gemspec but `dexpace-transport-async_http`'s, which isolates `async-http`'s higher floor as `>= 3.3` read from `VERSIONS`' per-gem `floor:` row — **NFR-10**'s isolated unit, never a dependency of core; CI matrix 3.2 / 3.3 / 3.4 / 4.0 running the real suite, the 3.2 row over the other five gems. [Amended 2026-09-25, `C25` of `docs/deviations.md`: the row read "in every gemspec"; phase 8c's `P8-36`, as built in `gems/dexpace-transport-async_http/dexpace-transport-async_http.gemspec`] | | Version/coordinate single source (**NFR-14**) | Root `VERSIONS` file read by every gemspec; one root `Gemfile` | | Reproducible artifacts (**NFR-12**) | `SOURCE_DATE_EPOCH` honoured by `gem build`; `spec.files` sorted deterministically | -| License headers (**NFR-13**) | SPDX header per file, checked by a RuboCop custom cop | +| License headers (**NFR-13**) | SPDX header per file, checked by a RuboCop custom cop, and `gates:spdx_rbs` over every shipped `.rbs` | | Runtime version metadata (**NFR-15**) | `Dexpace::VERSION` is the gemspec's own source; the User-Agent reads it, never a placeholder | | Signed publications (**NFR-16**) | Signed `gem push` on the release path only, optional locally | | Dependency CVE scanning | `bundler-audit` in CI, which is what enforces §3.4's `json >= 2.19.9` floor over time | | API documentation | YARD, with an undocumented-public-method gate | +| Serde independence of streaming and paging (**SSE-37**) | `gates:serde_boundary`: a parsed scan of `lib/dexpace/sse/**`, `lib/dexpace/page/**` and their `sig/` mirrors for any serialization dependency | +| One cycle-safe cause walk (**XCUT-9**) | `gates:cause_walk`: `Dexpace.each_cause` is the only walk of a cause chain in any gem's `lib/` | +| Bounded caller-keyed state (**XCUT-14**) | `gates:bounded_map`: only `Dexpace::BoundedMap` holds a caller- or server-keyed map | +| Core names no concrete seam implementation (**SEAM-2**) | `gates:seam_names` | +| The deviation ledger stays tied to this chapter | `gates:ledger_audit`: `docs/deviations.md`'s rows match §10's entries by subject, position and ID set, and every verdict cites real code | +| One parse in the tooling (**NFR-6**) | `gates:sole_parse`: `AstScan.parse` is the only `RubyVM::AbstractSyntaxTree.parse_file` in `tools/` | + +[Amended 2026-09-25, `C16` of `docs/deviations.md`: the table carried none of the seven gates later phases built +— `gates:serde_boundary` (phase 7b), `gates:cause_walk`, `gates:bounded_map` and `gates:seam_names` (phase 9) and +`gates:ledger_audit`, `gates:spdx_rbs` and `gates:sole_parse` (phase 10) — the last three rows above plus the +`NFR-13` row's second half. As built, the root `Rakefile`'s `DEFAULT_GATES` names twenty-four gates, each +described in `tasks/gates.rake`. Beside them the hand-run documentation probe gained a ninth check, `chapters` +(`.claude/skills/housekeeping/chapters.rb`), which reports a requirement ID attributed to a specification chapter +that does not carry it; it is not a build gate.] ### 9.1 API-surface lock, and the honest limits of RBS @@ -68,8 +82,11 @@ only actually running the suite on 3.2 catches it — which is why the matrix ru widely used framework in the Ruby application world and has the richer matcher library, the better failure output on complex expectations, and shared-example groups that would express the conformance suite's per-adapter parametrisation more naturally than Minitest's module-inclusion idiom. Minitest wins for one reason that outranks -all of that here: **it ships with the interpreter as a default gem**, so the same argument §2.4 makes about -`base64` and `logger` applies to the test framework — a first-party suite that runs with nothing installed is a +all of that here: **it ships with the interpreter as a bundled gem**, needing an explicit bundle entry under +Bundler — `VERSIONS` carries it, pinned `~> 5.25` — and the same argument §2.4 makes about `base64` and `logger` +applies to the test framework [Amended 2026-09-25, `C9` of `docs/deviations.md`: this read "as a default gem". +Minitest is a bundled gem on every supported Ruby; as built, `VERSIONS`' `tool minitest ~> 5.25` row, read by the +root `Gemfile`, is the entry, with its reason beside it] — a first-party suite that runs with nothing installed is a suite an adapter author can run, and one that needs a third-party assertion DSL is a dependency this project would be imposing on every adapter author who would rather not have it. That reason applies with real force only to `dexpace-conformance`, which is why the gem takes it further: **its assertions are plain assertion objects** — @@ -109,5 +126,9 @@ against the pivot, **ASYNC-8**–**ASYNC-12** against fiber storage, and **ASYNC **B.9** is exercised as §9's table, with NFR-8/NFR-9 inapplicable by their own text and replaced by §9.2. A failing item that the port has decided not to satisfy is reported as a failure by the suite and suppressed in the port's own build through a named waiver listing the requirement ID, so the gap stays visible rather than disappearing into -a restated item. +a restated item; and the report's unit is the same requirement ID — one assertion per ID, an appendix-B item a +many-to-one view whose status is the worst of its assertions. [Amended 2026-09-25, `C10` of `docs/deviations.md`: +the report's unit was unstated, and it cannot be the item, since B.7's second item is vacuous for **ASYNC-4** and +failing for **ASYNC-3** at once. As built, `Dexpace::Conformance::Report` carries one `Result` per assertion and +`gems/dexpace-conformance/APPENDIX_B.md` is the committed many-to-one map (phase 9's `P9-8`).] diff --git a/docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md b/docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md index 7f763bd..32d46ab 100644 --- a/docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md +++ b/docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md @@ -6,15 +6,37 @@ of the one prohibition in §8.3 — none narrows a MUST-level correctness guaran vacuously for the same reason and is collected with them in §10.5.** Each entry states the IDs it touches, whether the specification sanctions it or this port judged it, and where the full argument lives. Nothing new appears here. +[Corrected 2026-09-25, when this chapter was reconciled against the as-built tree rather than against the phase +documents that produced it. The bold sentence held for the nineteen entries the design wrote and is no longer the +whole count: three further MUST clauses are not met, each on a stated domain and each admitted rather than argued +away — the no-materialisation clause of SERDE-27 in the shipped JSON codec (entry 20), the header-name clause of +TRANSPORT-14 on the asynchronous transport (entry 21), and the no-pinning clause of HTTP-45 for a second fiber of one +thread with no fiber scheduler installed (entry 22). Four further entries narrow a MUST clause on an edge they state +and argue rather than admit — a frozen primary (6), a borrowed `Net::HTTP` (26), an explicit scheme-default port +(33) and a bearer provider with no `#fetch_async` (34). "Nothing new appears here" holds for entries 1–19 only. Entries +20–38 consolidate every genuine deviation from the reference contract that the phases' as-built ledgers recorded and +this chapter did not; each cites its ledger row — a phase-7 row with its sub-phase letter, because 7a, 7b and 7c +each number from `P7-1` — and the as-built code, and its full argument lives in that phase's design. Corrections to +entries 1–19 are dated in place, quoting what they replace. Ledger rows that record a departure from the design or +the plan while the specification is met exactly are not deviations and are not carried here.] + 1. **The byte-stream provider seam is retired; its behavioural contract is not.** *Touches* **SEAM-3**–**SEAM-10**, **IO-30**–**IO-36**, **IO-39**, **XCUT-23** as applied to this seam. *Judged.* Ruby's byte primitives ship with the interpreter, so nothing needs keeping out of core and there is nothing to make pluggable (P2). **IO-1**–**IO-29** and **IO-37**–**IO-42** are implemented in full; only the pluggability apparatus is removed. - §3.1. + §3.1. [Narrowed 2026-09-25 from phase 3a's as-built ledger, `P3-1`–`P3-3`: "in full" means under Ruby's + spellings. The tail-appending read of IO-1 is `#read_into(dest, count:)`, because `#read` and `#readpartial` + keep Ruby's overwrite semantics, which `IO.copy_stream` relies on; the host-native bridge of IO-16 ends with + `nil` or `Dexpace::EndOfStreamError < ::EOFError` rather than -1; and the use-after-close failure of IO-42 is + `Dexpace::ClosedError`, a `StandardError` rather than an `IOError`, because a `Dexpace::IOError` would shadow + Ruby's inside `module Dexpace`. As built in `gems/dexpace-core/lib/dexpace/io/typed_reads.rb`.] 2. **The canonical body is a duck type, not a nominal interface.** *Touches* **SEAM-3**, **BODY-1**, **BODY-35**. *Judged.* `#each` yielding BINARY `String` chunks is Rack's de facto standard, so every existing Ruby streaming body inter-operates with no adapter (P14). The inverse adapter `BufferedSource.over` is the single entry point - the other direction, and takes no ownership. §3.1. + the other direction, and takes no ownership. §3.1. [Narrowed 2026-09-25 from phase 3b's `P3-15`: the duck type is + what a body *yields*; the slot a `Request` or `Response` body occupies is typed `Dexpace::Body?` in `sig/`, which + is **HTTP-36**'s contract (a write-to-sink operation, a media type, a length and replayability), and nothing + coerces a bare Rack body into it at runtime.] 3. **The async pivot is a core-owned future rather than an ecosystem primitive.** *Touches* **SEAM-16**, **SEAM-17**, **ASYNC-1**, **ASYNC-2**. **Spec-sanctioned**: **SEAM-17** is a SHOULD that names the pattern, not the type, and explicitly wants "one canonical dependency-free future pivot ... ecosystem facades ... separate @@ -27,10 +49,18 @@ the specification sanctions it or this port judged it, and where the full argume are forbidden (§8.3). **SEAM-30**/**ASYNC-5**'s orphaned-response close is performed by the producer under the check-after-resume rule, and **CFG-21**'s discard-path close through the same helper — *how*, not *whether* (P6). Cancellation remains prompt at the future, which is what **XCUT-3** makes normative. §3.3, §3.7, §8.3. + [Extended 2026-09-25 from phase 2's `P2-4` and phase 4b's `P4-17`: the same substitution reaches two more IDs. + **SEAM-18**'s blocking bridge, told to "restore the interrupt flag … surface an interrupted-I/O error", has no + interrupt to restore — the flag clause is vacuous as ASYNC-4 is — and raises the typed `Dexpace::CancelledError` + (a `StandardError`, not an `IOError`) from `Dexpace::Bridge::SyncOver`; and **RECOV-11**'s re-assert-on-wrap has + no action to perform, because the token is a latch computed from its sources, so converting a `CancelledError` + into a Failure cannot clear it.] 5. **Two MUSTs are not satisfied, and a third holds vacuously: ASYNC-3, PIPE-33's interrupt clause, and ASYNC-4.** *Touches* **ASYNC-3**, **ASYNC-4**, **PIPE-33**. *Judged, and admitted rather than argued away* (P8). These are the only entries in this catalogue that are not mechanism substitutions, and they are not equivalent to one - another, which is why they are separated here. + another, which is why they are separated here. [Corrected 2026-09-25: "the only entries" held for the nineteen; + entries 20–22 admit three more unmet MUST clauses, 29 records two vacuities, and several of 23–38 are readings of + two colliding MUSTs rather than substitutions. These three remain the only ones caused by §8.3's prohibition.] - **ASYNC-4 holds vacuously, and that is a real argument.** It requires an interrupt-ordering handshake so "a stale interrupt cannot poison a pooled thread." It is a negative guarantee about a hazard interrupts create; a port that never delivers an interrupt cannot produce the hazard, so the guarantee holds — and holds more @@ -49,7 +79,11 @@ the specification sanctions it or this port judged it, and where the full argume *Why:* §8.3's prohibition on `Timeout.timeout`, `Thread#raise` and `Thread#kill`, which exists because an asynchronous interrupt in Ruby can land on any bytecode instruction, including inside an `ensure` releasing a pooled connection. *Mitigation:* the check-after-resume rule (§3.3) aborts the worker at its next resume point, - and `Completer#on_cancel` lets an adapter shorten that by closing the socket under the read; the future itself + and `Completer#on_cancel` lets a **transport** shorten that by closing its socket under the read — the thread-pool + adapter owns no socket [Amended 2026-09-25, `C12` of `docs/deviations.md`: this read "lets an adapter shorten + that"; `dexpace-async-thread` posts an opaque block and can register no such hook, and the hooks that exist are + the two transports' — `ResponsePump` in `gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/` and + `Exchange` in `gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/`]; the future itself completes as cancelled promptly either way, so no caller waits on work it has abandoned. *Residual gap, stated precisely:* **a transport blocked inside an uninterruptible C-extension read cannot be aborted early**, so its worker occupies a pool slot until that read returns on its own. The consequence is bounded worker occupancy @@ -57,7 +91,14 @@ the specification sanctions it or this port judged it, and where the full argume §5.3, §8.3, §12. 6. **Suppressed exceptions are a core-owned trail, not a host facility.** *Touches* **RECOV-12**, **PAGE-13**, **PAGE-15**, **SSE-29**, **SSE-30**, **SSE-36**, **RETRY-34**, **XCUT-9**. *Judged.* Ruby has `Exception#cause` - (single causal parent) and nothing else; `Dexpace::Error#suppressed` supplies the list, `#cause` is never + (single causal parent) and nothing else; `Dexpace::Suppressible` supplies the list — `Dexpace::Error` includes it + and `Dexpace.attach_suppressed` extends it onto any other exception [Amended 2026-09-25, `C14` of + `docs/deviations.md`: this read "`Dexpace::Error#suppressed` supplies the list". Every primary RECOV-12, + `close_quietly` and `Hooks.notify` hand the helper can be a caller's own exception, so the trail cannot live on + the SDK's root alone; as built it is `gems/dexpace-core/lib/dexpace/suppressible.rb` (phase 4b's `P4-12`). One + narrowing rides with it (`P4-13`): attaching extends a caller's exception visibly, and on a **frozen** primary the + attach is a documented no-op, so the secondary error is lost rather than raised over the primary — the one case + where RECOV-12's attachment is not met], `#cause` is never overloaded to mean "failed while cleaning up," and every cause walk goes through one `equal?`-tracking enumerator so **XCUT-9**'s cycle-safety is a property of the helper rather than of each call site. §5.2. 7. **"Standard library" is narrowed to what is stable across the supported Ruby range.** *Touches* **SEAM-1**, @@ -74,9 +115,18 @@ the specification sanctions it or this port judged it, and where the full argume **SEAM-10**. *Judged.* Ruby has one process-global constant namespace and no classloader; two copies of core cannot yield two distinct classes. The real Ruby risk is an adapter built against a different core version, which the `~>` constraint plus a registration-time assertion covers, audited by §9's single-instance gate. §2.4, §3.6. + [Extended 2026-09-25 from phase 8b's `P8-21`: the thread-pool adapter has no registry to register into — there is + no executor seam, and **SEAM-18** forbids a default executor — so `dexpace-async-thread` makes the same + `Gem::Requirement` check directly at require time against `Dexpace::Async::Thread::REQUIRED_CORE`.] 10. **Runtime encapsulation of models is partially unachievable.** *Touches* **HTTP-2**/**SEAM-29**, **HTTP-4**, **HTTP-7**, **IO-28**/**BODY-37**. *Judged, and admitted rather than closed* (P8). `private_class_method :new` is - bypassable by `send` by design, and duck typing admits impersonation. The official path is genuinely closed, the + bypassable by `send` by design — which bypasses `.build` but not validation, since every HTTP domain model's + `#initialize` validates before `super`; what stays open is `.allocate`, which yields an instance whose members + are all `nil` — and duck typing admits impersonation. [Amended 2026-09-25, `C19` of `docs/deviations.md`: the + sentence read only "`private_class_method :new` is bypassable by `send` by design", which §4 had glossed as + reaching "the generated constructor". As built, `Dexpace::Request.send(:new, …)` with a GET and a body raises the + HTTP-7 refusal from `gems/dexpace-core/lib/dexpace/http/request.rb`, and `Request.allocate` answers `nil` for all + four members.] The official path is genuinely closed, the gap is documented, and — the part that matters — header validation (**HTTP-17**/**HTTP-18**/**XCUT-18**) is re-run at the model-to-wire boundary so a forged model cannot smuggle a request-splitting header. §4. 11. **Read-only collection exposure is computed once, not wrapped per access.** *Touches* **HTTP-5**, **XCUT-15**. @@ -87,10 +137,18 @@ the specification sanctions it or this port judged it, and where the full argume 12. **One stream-ownership rule for bodies, resolving a reference inconsistency.** *Touches* **BODY-8**, **SEAM-3**, **SEAM-21**. **Spec-sanctioned**: **BODY-8** requires the port to decide ("A port MUST decide its stream-ownership/close rule deliberately rather than assume every single-use body closes its input"). A body - closes exactly the sources it opened; ownership-on-wrap remains the I/O layer's rule and is deliberately not the - body layer's, and the serde seam's streaming variants close nothing (**SEAM-20**/**SEAM-21**). §3.1, §3.4, §3.7. + closes exactly the sources it opened; ownership-on-wrap remains the I/O layer's rule (**IO-6**) and is deliberately + not the body layer's [Amended 2026-09-25, `C3` of `docs/deviations.md`: §3.1 attributed the I/O rule to SEAM-3, + the ID entry 1 retires; the live ID is IO-6, and `Dexpace::IO::BufferedSource.wrapping` is its as-built + home], and the serde seam's streaming variants close nothing (**SEAM-20**/**SEAM-21**). §3.1, §3.4, §3.7. 13. **The serde seam ships four encode profiles, two of which are one Ruby type.** *Touches* **SEAM-20**. - *Judged.* `#dump_string`, `#dump_bytes`, `#dump_to(sink)` and `#dump_into(buffer, offset:)` all ship; a BINARY + *Judged.* `#dump_string(value)`, `#dump_bytes(value)`, `#dump_to(value, sink)` and + `#dump_into(value, buffer, offset: 0)` all ship [Amended 2026-09-25, `C18` of `docs/deviations.md`: the four + were spelled without the value they encode, as `#dump_to(sink)` and `#dump_into(buffer, offset:)`; as built in + `gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`. Narrowed the same day from phase 7a's `P7-5`: the + buffer profile's target is a mutable BINARY `String` only — Ruby's `IO::Buffer` is refused, because it warns as + experimental and raises `ArgumentError` where `String#[]=` raises `IndexError`, the single range error + **SERDE-4** asks for]; a BINARY `String` *is* Ruby's byte array, so the fresh-string and fresh-byte-array profiles differ only in the encoding tag the result carries. Both are kept because the tag is load-bearing at §3.1's encoding boundary. Argued clause-by-clause rather than reduced (P5). §3.4. @@ -104,7 +162,10 @@ the specification sanctions it or this port judged it, and where the full argume **AUTH-29**, **PIPE-16**. *Judged, and stronger.* Forgery becomes structurally impossible rather than defended against, and the porter trap **REDIR-11** itself documents — a pipeline with no auth step forwarding the marker to the transport — cannot occur. Rests on §5.1's two cursor-state rules and the stage order - REDIRECT → RETRY → AUTH. §5.1, §6.2. + REDIRECT → RETRY → AUTH. §5.1, §6.2. [Stated as built 2026-09-25 from phase 4c's `P4-28` and `P4-29`: cursor + state is keyed by `(stage, key)`, written only as `Cursor#fork(state:)` into the forking pillar's own slot and + read through `#state(stage)`; `Dexpace::Pipeline::Cursor` has no setter, so even the RETRY pillar that sits + between REDIRECT and AUTH cannot write the slot AUTH reads.] 16. **The configuration chain keeps four tiers with a substituted third source.** *Touches* **CFG-1**, **CFG-3**, **CFG-4**, **CFG-24**, **CFG-26**, **OBS-35**. *Judged.* Ruby has no system properties; the `Dexpace.configure` defaults tier is a genuinely distinct in-process source, not a second `ENV` read under another name (P11). @@ -114,19 +175,214 @@ the specification sanctions it or this port judged it, and where the full argume 17. **The interruptible sleep is a cancellable queue wait, not `Kernel#sleep`.** *Touches* **CFG-15**, **CFG-17**, **CFG-18**, **RETRY-26**, **XCUT-3**, **XCUT-13**. **Spec-sanctioned** in part: **XCUT-3** states "the normative requirement is prompt cancellation, not the specific mechanism." Satisfies **CFG-15**'s blocking-and-interruptible - clause and **RETRY-26**'s no-pinning clause simultaneously, and is what lets §3.7's graceful close wait without - parking a cancelled caller. §3.7, §8.3. + clause and **RETRY-26**'s no-pinning clause simultaneously. §3.7, §8.3. [Corrected 2026-09-25: the entry + ended "and is what lets §3.7's graceful close wait without parking a cancelled caller". No close path calls + `Clock#sleep`: its only callers are the two retry drivers. As built, §3.7's graceful close is bounded rather + than cancellable — `Dexpace.close_quietly` calls `#close` with no arguments, so `Dexpace::Async::Thread::Pool`'s + drain waits at most its construction-time `shutdown_timeout` and takes no token (phase 8b's `P8-24`, + `gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb`), and the interrupt-safety clause of **ASYNC-15** + is met by that bound and by the flag never being touched.] 18. **Platform-constant substitutions where Ruby has no constant.** *Touches* **IO-9**, **BODY-32**, **SSE-11**, **RECOV-34**. **Partly spec-sanctioned**: **SSE-11** requires the port to pick a documented cap. Ruby has no maximum single allocation and no integer overflow, so the port names explicit constants (64 MiB materialisation ceiling, 2^31−1 ms retry hint, an explicit ~292-year duration bound in retry config) and fails or ignores loudly above them, preserving the observable behaviour on a host where the stated failure mode is - unreachable. §3.1, §6.1, §7.2. + unreachable. §3.1, §6.1, §7.2. [Amended 2026-09-25, `C15` of `docs/deviations.md`: the list omitted + `SSE::MAX_LINE_BYTES` (1 MiB) and `SSE::MAX_EVENT_BYTES` (8 MiB), **SSE-19**'s two rejecting caps, distinct from + SSE-11's `MAX_RETRY_MS` (phase 7b's `P7-21`). The line cap is sanctioned by chapter 13's own SSE-19 port clause; + the event cap is judged, since appendix C's "no maximum line or event size" is what it departs from. Three more + narrowings from the as-built ledgers: the materialisation ceiling is read per call through + `Dexpace::IO.max_materialized_bytes` from its configuration key, so `IO::MAX_MATERIALIZED_BYTES` is the default and + the fallback rather than a fixed bound, and it governs every contiguous materialisation including + `#read_exactly` (phase 5a's `P5-56`, phase 3a's `P3-4`); and the pacing-header parser bounds each digit run at + fifteen digits and a value at 64 bytes, the largest a `Float` carries exactly, below which **RETRY-18** clamps + and above which **RETRY-16** answers no hint (phase 6a's `P6-61`).] 19. **The dead-code-survival gate is retargeted, not deleted.** *Touches* **NFR-8**, **NFR-9**. **Spec-sanctioned**: **NFR-8** says "In ecosystems without such a build step this requirement does not apply." Retargeted at the require-allowlist audit and clean-bundle isolation run, which guard the Ruby-specific way a zero-dependency claim silently stops being true. §9.2. +20. **SERDE-27's no-materialisation clause is not satisfied by the shipped JSON codec.** *Touches* **SERDE-27**, + **SERDE-12**, **IO-9**. *Judged, and admitted rather than argued away.* Added 2026-09-25 from phase 7a's `P7-1`. + No `json` entry point at or above the 2.19.9 floor parses from an IO except `JSON.load`, which §3.4 bans for + `create_additions`, so `Dexpace::Serde::JSON::Codec#load` drains the source into one `String` under + `Dexpace::IO.max_materialized_bytes` and a larger body raises `Dexpace::StreamError` unwrapped (**SERDE-12**). + The deviation is adapter-local: `Dexpace::Serde::DecodingHandler` materialises nothing and hands `#load` the + unread `BufferedSource`, so a codec over a pull parser satisfies the clause with no change to core, the handler + or the seam. The requirement's other three MUST clauses — consume and close on every path, a missing body surfaced + naming the target, a parse failure chained as the cause while a mid-stream I/O error propagates unwrapped — are + met. As built: + `gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb`; the gap is also `docs/first-release.md` § What v1 + ships without › Unsatisfied MUSTs. +21. **TRANSPORT-14's header-name clause is unreachable on the asynchronous transport.** *Touches* **TRANSPORT-14**, + **TRANSPORT-27**. *Judged, and admitted rather than argued away.* Added 2026-09-25 from phase 8c's `P8-38`, + with the TRANSPORT-27 half that ledger row did not carry. `protocol-http1` raises out of the read on a control or + non-ASCII byte in an inbound header **name**, and on an unparseable `Content-Length`, so no response object + exists to drop the one header from or to downgrade to the unknown-length sentinel: the whole response fails as a + retryable `Dexpace::TransportError`. The value-side clauses hold on both adapters, and both clauses hold on + `dexpace-transport-net_http`. The gap is bounded to the one malformed response and is carried as a named + conformance waiver listing both IDs, + `gems/dexpace-transport-async_http/test/dexpace/transport/async_http/conformance_test.rb`'s `WAIVED`. +22. **HTTP-45's no-pinning clause holds for threads, not for a second fiber of one thread with no scheduler.** + *Touches* **HTTP-45**, **BODY-22**. *Judged, and admitted rather than argued away.* Added 2026-09-25 from phase + 3b's `P3-27` and `P3-28`, which recorded it in that phase's documents alone. The once-only parse of + `Dexpace::TypedResponse#value` and the once-only drain of `Dexpace::ResponseLoggingBody` serialise through a + `Thread::Mutex` and a `Thread::ConditionVariable` held across the state flip only; both defer to an installed + `Fiber.scheduler`, which is the non-pinning HTTP-45 asks for. With no scheduler — Ruby's default — a second + fiber of the same thread arriving mid-parse parks the carrier thread, and deadlocks the process if the first + fiber is never resumed. Two threads, and a fiber arriving after the parse settled, are unaffected. Ruby has no + scheduler-independent primitive that serialises without parking. As built: + `gems/dexpace-core/lib/dexpace/http/typed_response.rb`, + `gems/dexpace-core/lib/dexpace/http/body/response_logging_body.rb`. +23. **The orchestrator's "every throwable" is every `StandardError`.** *Touches* **RECOV-2**, **RECOV-8**, + **PIPE-30**. *Judged.* Added 2026-09-25 from phase 4b's `P4-19` and phase 4c's `P4-52`. The recovery + orchestrator, the response chain and the async driver convert every `StandardError` into a Failure; the fatal + family outside it (`NoMemoryError`, `SystemExit`, `Interrupt`, `ScriptError`) propagates unconverted, which is + the reading of PIPE-30's "fatal/unrecoverable errors MUST propagate" this port applies to all three; and + `Dexpace::OutcomeError` — a fold handed something that is not an `Outcome` — is re-raised by name rather than + demoted into a Failure a recovery step could swallow, because it is the caller's programming defect. As built: + `Dexpace::Recovery::Orchestrator#call` in `gems/dexpace-core/lib/dexpace/recovery/orchestrator.rb`. +24. **Retryable throwables are classified by capability, not by I/O type.** *Touches* **RETRY-2**, **RECOV-17**, + **XCUT-4**, **XCUT-6**. *Judged, with partial sanction* — RECOV-17 itself asks for a capability. Added + 2026-09-25 from phase 6a's `P6-4`. RETRY-2 defines the retryable set as any throwable that is, or has in its cause + chain, an I/O or timeout error; `Dexpace::Resilience::Policy.throwable_retryable?` asks only `#retryable?` over + `Dexpace.each_cause`, on both stacks, because a type match is wrong both ways — a wrapped `ProtocolError`, or an + I/O error a caller deliberately marks terminal. The stated blind spot — a bare stdlib I/O error escaping an + adapter unwrapped classifies not retryable — is closed by obligation: both shipped transports wrap every + no-response failure in `Dexpace::TransportError`, whose `#retryable?` is true. As built: + `gems/dexpace-core/lib/dexpace/resilience/policy.rb`. +25. **The asynchronous transport runs only inside a reactor, and its response does not outlive that reactor.** + *Touches* **TRANSPORT-21**, **TRANSPORT-25**. *Judged.* Added 2026-09-25 from phase 8c's `P8-39` + and `P8-94`. `Dexpace::Transport::AsyncHTTP` creates no reactor: called with no `Async::Task` current it returns + an already-failed future carrying `Dexpace::SeamError` — TRANSPORT-21's channel, never a synchronous raise — and + a caller must read the body before the `Sync` block that produced the response ends, because the reactor's + teardown drains the connection pool and waits on the connection behind an unread body, so TRANSPORT-25's lazily + read body is lazy within the reactor's lifetime only. The specification states no such precondition; + `async-http` imposes it. As built: + `gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/adapter.rb` (`REACTOR_MESSAGE`). +26. **A borrowed `Net::HTTP` is used verbatim: one origin, serialised calls, no per-call timeout.** *Touches* + **TRANSPORT-5**, **TRANSPORT-15**, **TRANSPORT-19**, **XCUT-22**. *Judged.* Added 2026-09-25 from phase 8a's + `P8-6`, `P8-15` and `P8-53`. TRANSPORT-5's per-call override and XCUT-22's leave-the-caller's-client-untouched + rule contradict each other on a `Net::HTTP`, whose timeouts are instance state, so + `Dexpace::Transport::NetHTTP.using` refuses a per-call timeout with `InvalidArgumentError` rather than + silently ignoring it; it also refuses a request naming any origin but the client's own, and serialises calls + through one permit held for the life of each response, so TRANSPORT-19's prompt unblock waits on the caller's + own `read_timeout`. The owning construction, `.build`, has none of these limits, and the asynchronous adapter's + `.using` applies a per-call timeout as a task deadline. As built: + `gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/adapter.rb`. +27. **ASYNC-18's delay blocks no caller and no worker, but one timer thread per pool.** *Touches* **ASYNC-18**. + *Judged.* Added 2026-09-25 from phase 8b's `P8-25`. `Dexpace::Async::Thread::Pool#delay` parks neither the + caller nor any pool worker; one lazily created timer thread per pool waits out every interval, and cancelling a + delay removes its entry, so no thread is held for it. `Fiber.scheduler` is per thread, so core's `Async.delay` + is unavailable on a worker, and a thread per delay or a worker used as the timer would be unbounded or would + starve the pool. As built: `gems/dexpace-async-thread/lib/dexpace/async/thread/timer.rb`. +28. **CFG-18's delay settles with `true` and refuses to block.** *Touches* **CFG-18**, **SEAM-16**. *Judged, + forced by a MUST.* Added 2026-09-25 from phase 5a's `P5-9` and `P5-52`. SEAM-16 makes a nil-valued success + unconstructible, so `Dexpace::Async.delay` settles with `true`, the smallest non-nil value, rather than the + "empty/void value" CFG-18 describes; and with no `Fiber.scheduler` a positive delay raises `Dexpace::SeamError` + rather than degrading to a thread-backed wait that would meet the MUST clauses while falsifying the SHOULD's + headline, "without blocking a thread". A zero delay completes inline and a negative one is refused. As built: + `gems/dexpace-core/lib/dexpace/async/delay.rb`. +29. **Two clauses are vacuous by a false antecedent: CFG-19's unwrap and PAGE-15's wrapping.** *Touches* + **CFG-19**, **PAGE-15**. *Judged; the same shape as §11.15.* Added 2026-09-25 from phase 5a's `P5-11` and phase + 7c's `P7-1`. Ruby has no completion or execution wrapper exception — `Completer#fail` and `Future#value` + deliver the caller's own object — so CFG-19's "a non-wrapper throwable MUST be returned unchanged" holds for + every input, and shipping an unwrap would `NFR-4`-lock an identity function; the observable half is asserted + with `assert_same`. And no Ruby iteration terminal "cannot declare the underlying I/O error type", so PAGE-15's + wrap-to-declare clause has nothing to wrap: a page close failure is raised as itself. Both IDs are implemented + for every clause that has a subject. As built: `gems/dexpace-core/lib/dexpace/async/completer.rb`, + `gems/dexpace-core/lib/dexpace/page/closing.rb`; §12 lists both among the vacuous entries. +30. **Where OBS-11's userinfo redaction meets OBS-16's "returned verbatim", OBS-11 wins.** *Touches* **OBS-11**, + **OBS-16**, **OBS-14**, **HTTP-19**. *Judged; resolves two MUSTs that collide.* Added 2026-09-25 from phase 5b's + `P5-100` and `P5-107`. A relative header value's network-path authority, and every `//`-authority in a value the + URI parser rejected wherever it sits, has its userinfo replaced by `***:***`, whatever **HTTP-19**-admitted bytes + precede it; every other byte is written back as it came, and what RFC 3986 gives no authority (a path spelling + a second one) stays verbatim. A rejected value has no grammar left to honour, and a tolerated-prefix list is + always one prefix short, so OBS-11's "unconditionally" is read literally. As built: + `Dexpace::Instrumentation::Redactor` in `gems/dexpace-core/lib/dexpace/instrumentation/redactor.rb`. +31. **The diagnostic context restores, snapshots and folds with three stated narrowings.** *Touches* **OBS-10**, + **OBS-23**, **OBS-24**, **XCUT-20**. *Judged.* Added 2026-09-25 from phase 5c's `P5-49` and `P5-72` and phase + 5b's `P5-22`, `P5-39` and `P5-97`. OBS-23's "remove it if previously unset" and "restore its prior value" share + one assignment, `Fiber[k] = prior`: on 3.3 and later a present-but-nil key restores as absent, and on the 3.2 + floor, whose `Fiber` API has no removal, a previously unset key restores as present-and-nil — both unobservable + at `Fiber[]`, and OBS-10 skips a nil value, while `Diagnostics.capture` compacts nil-valued keys so the floor's + retained nils never travel. OBS-24's snapshot is immutable (a shallow-frozen `Hash`) and makes no shareability + claim, because `Ractor.make_shareable` over a host's unshareable value raises from a logging path, which XCUT-20 + forbids. And OBS-10's unfiltered mode folds every present key except those under + `Diagnostics::RESERVED_PREFIX` (`dexpace.`), where core keeps its current-span slot. As built: + `Dexpace::Instrumentation::Diagnostics` in `gems/dexpace-core/lib/dexpace/instrumentation/diagnostics.rb`. +32. **Trace identity follows the flavour, and the no-op tracer is shared across operations.** *Touches* + **OBS-26**, **OBS-27**, **OBS-29**, **OBS-25**, **CTX-15**. *Judged; each resolves two MUSTs in literal + conflict.* Added 2026-09-25 from phase 4a's `P4-7` and phase 5c's `P5-43`. OBS-26's invalid trace id of 32 hex + zeros is exact for the W3C and no-op flavours and for `Bundle::NONE`; OBS-27's Datadog flavour renders decimal, + where that sentinel is not expressible, so its invalid id is `"0"`, while the span-id sentinel stays one + constant. And OBS-29's "one tracer instance corresponds 1:1 to one logical operation" binds a stateful tracer: + OBS-25 requires the no-op factory to hand out one shared tracer with no per-call allocation, so + `NO_TRACER_FACTORY#tracer` answers `NO_TRACER` every time, and the 1:1 obligation is stated on `_Tracer` as an + implementer contract and asserted against a factory that returns a fresh instance per call. As built: + `Dexpace::Instrumentation::TraceIdFlavour`, `Dexpace::Instrumentation::NO_TRACER_FACTORY`. +33. **A redirect predicate overrides the follow decision but not the hop cap, and an explicit default port is not + kept.** *Touches* **REDIR-13**, **REDIR-17**, **REDIR-18**, **REDIR-19**, **REDIR-20**, **REDIR-21**, + **REDIR-23**. *Judged.* Added 2026-09-25 from phase 6b's `P6-91` and `P6-96`. REDIR-20 says a configured + predicate "fully overrides the built-in follow decision"; the step consults it on every recognised 3xx + (REDIR-21) and then lets REDIR-17's cap veto a `true`, and a missing or unresolvable target returns the response + unfollowed whatever it answered, because REDIR-17, REDIR-18 and REDIR-19 are MUSTs with no carve-out and an + uncapped predicate would make REDIR-23's stack safety unbounded. Separately, REDIR-13's "preserve explicit + ports" holds for every non-default port but not for an explicit scheme default: `URI#to_s` elides `:443` and + `:80` and `URL.parse!` re-parses from the text, so `Location: https://h:443/y` reaches the wire as + `https://h/y`. The origin triple REDIR-8 and AUTH-29 compare is unchanged. As built: + `Dexpace::Redirect::Step` in `gems/dexpace-core/lib/dexpace/redirect/step.rb`. +34. **Four authentication clauses yield to the port's other rules.** *Touches* **AUTH-16**, **AUTH-22**, + **AUTH-23**, **AUTH-31**, **AUTH-36**, **AUTH-37**, **AUTH-11**, **HTTP-18**. *Judged.* Added 2026-09-25 from phase + 6c's `P6-2`, `P6-5`, `P6-7` and `P6-76` (cited with the sub-phase letter, since 6a's rows share those numbers). + AUTH-37's off-thread background refresh is off-thread only if the provider is: the SDK owns no thread, so + `Dexpace::Auth::AsyncBearerStamper` calls the provider's `#fetch_async` and never awaits it, and a provider with + `#fetch` alone is mirrored into a settled future per AUTH-11, so its blocking fetch runs inline on the + dispatching fiber. AUTH-23's first-handler-whose-can-handle-passes is one answer-or-`nil` call, so a handler that + recognises a challenge but cannot answer it defers to the next rather than ending the chain. AUTH-36's + eviction-driven retry is still skipped for a non-replayable body, surfacing the 401 unchanged, in the direction + of AUTH-31's own note. And Digest yields to the outbound header grammar of HTTP-18: a non-ASCII username goes out + as RFC 7616's `username*=UTF-8''…` rather than AUTH-22's quoted form, and a challenge whose `realm`, `nonce` or + `opaque` the grammar cannot echo is declined, narrowing AUTH-16's satisfiable set by exactly the challenges no + conforming request could answer. As built: `gems/dexpace-core/lib/dexpace/auth/`. +35. **Two pagination clauses are met by stated readings.** *Touches* **PAGE-16**, **PAGE-19**, **PAGE-10**. + *Judged.* Added 2026-09-25 from phase 7c's `P7-5`, `P7-117` and `P7-6`. RFC 3986's same-document references — + `<>` and `<#…>` — resolve successfully to the current URL, and followed literally they re-fetch the current page + until a cap PAGE-10 defaults to unbounded, so `Dexpace::Page.next_request_from` reads both forms off the raw + target and answers end-of-stream, while ``, `` and the current URL spelled out are still followed. And + PAGE-16's single read of the body is the extractor's contract rather than core's: the strategies take a + caller-supplied `#call(response)` extractor and never a codec — the serde independence the specification's + pagination chapter demands — so core calls the extractor exactly once per page and cannot enforce how often it reads. + As built: `gems/dexpace-core/lib/dexpace/page.rb`, `gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb`. +36. **SERDE-24's round-trip holds on a stated precision domain.** *Touches* **SERDE-24**. *Judged.* Added + 2026-09-25 from phase 7a's `P7-8`. `Dexpace::Serde::Instant` emits ISO-8601 at microsecond width through + `Time#iso8601(6)`, which truncates: every `Time` whose sub-second part is an exact multiple of a microsecond — + every one the SDK constructs or decodes — round-trips exactly, and a float-derived one need not + (`0.123456` → `.123455`). Rounding would mean a second date formatter beside `HTTPDate`. As built: + `gems/dexpace-core/lib/dexpace/serde/instant.rb`. +37. **Three model clauses take a Ruby shape.** *Touches* **HTTP-9**, **HTTP-34**, **HTTP-1**, **BODY-9**, + **HTTP-38**. *Judged.* Added 2026-09-25 from phase 1's `P1-10` and `P1-14` and phase 3b's `P3-16`. HTTP-9's + idempotent set is a public constant, `Dexpace::Method::IDEMPOTENT`, with `#idempotent?`, rather than "an internal + constant": the single-source clause is met exactly, and the two consumers read it by receiver from sibling files, + which a private predicate cannot serve without a second copy of the set. `RequestOptions` tag values are narrowed + to `String`, where HTTP-34 fixes string keys and HTTP-1 leaves values opaque, so the map can be deep-copied and + deep-frozen at construction; narrowing is the direction a later release can widen. And BODY-9's "supports + mark/reset" is seekability, probed at construction with `pos` and `seek(pos)` — `respond_to?(:rewind)` answers + true for a pipe — with each replay seeking back to the construction position rather than byte 0. As built: + `gems/dexpace-core/lib/dexpace/http/method.rb`, `gems/dexpace-core/lib/dexpace/http/request_options.rb`, + `gems/dexpace-core/lib/dexpace/http/body/stream_body.rb`. +38. **Two defects are detected as far as Ruby lets them be.** *Touches* **PIPE-15**, **PIPE-5**, **BODY-11**, + **BODY-17**. *Judged, and admitted rather than closed* (P8). Added 2026-09-25 from phase 4c's `P4-33` and + `P4-37` and phase 3b's `P3-21` and `P3-28`. PIPE-15's reuse of a spent cursor is detected sequentially: the + single-use latch is an unsynchronised flag, because a cursor is per step invocation and never published, and a + mutex would put a non-reentrant lock on every step of every call to catch a caller defect, so a concurrent double + use is not guaranteed to raise (measured at roughly 1.5 % on 3.2.11). PIPE-5's "distinct cross-stage error" is a + distinct message on the one `Dexpace::PipelineError`. And a body driven through a caller's own abandoned + external `#each` keeps what it held — an `ensure` never runs in an `Enumerator` abandoned mid-`#next` — so a + `FileBody` leaks the fresh handle BODY-11 opens for that write and a replayable `StreamBody` keeps its rewind + guard and refuses every later write, the safer failure; internal drives always complete. As built: + `gems/dexpace-core/lib/dexpace/pipeline/cursor.rb`, `gems/dexpace-core/lib/dexpace/http/body/file_body.rb`. + Two things that are deliberately **not** deviations, recorded because a reader may expect them: the two retry stacks are **not** unified, so neither **RETRY-28**'s nor `08-execution-pipelines.md`'s unification sanction is invoked (§6.1); and both transport seams and both pipeline bridges survive, so no analogue of a single-execution-model diff --git a/docs/sdk-design-ruby/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md b/docs/sdk-design-ruby/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md index d3a7dc2..484894c 100644 --- a/docs/sdk-design-ruby/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md +++ b/docs/sdk-design-ruby/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md @@ -29,7 +29,11 @@ than worked around silently, so a future reader can tell a deliberate reading fr non-reentrant (both verified), so holding it across a suspension point would deadlock two fibers of one thread. The general lesson applied reflexively: port the requirement, not the reference's mechanism. 8. **XCUT-23's resolution rule needs at least one instance after a seam retires.** *Resolved:* it has three here - (transport, serde, executor), satisfied by §3.6, so the conformance item is accounted for. + (transport, async transport, serde), satisfied by §3.6, so the conformance item is accounted for. [Amended + 2026-09-25, `C17` of `docs/deviations.md`: the third instance read "executor". There is no executor registry; + the three registries are `Dexpace::Transport`'s, `Dexpace::AsyncTransport`'s + (`gems/dexpace-core/lib/dexpace/async_transport.rb:22`) and `Dexpace::Serde`'s, and `dexpace-async-thread` + asserts its version skew directly at require time because it has none to register with.] 9. **Appendix B covers only nine of nineteen prefixes.** *Resolved:* §9.3 states that Appendix B conformance is a strictly weaker claim than full conformance, and `dexpace-conformance` adds suites for the other ten. 10. **RETRY-38's modal tag contradicts its prose** (tagged SHOULD, body says "MAY stamp"): treated as SHOULD per @@ -57,9 +61,16 @@ than worked around silently, so a future reader can tell a deliberate reading fr the deviations are replicated — they are the specified behaviour, and interoperability with the reference matters more than WHATWG parity — and no strict mode ships in the MVP. 18. **SERDE-26 presumes a mutable codec engine; TRANSPORT-18 presumes a re-subscribable body producer.** Both are - near-vacuous for the MVP adapters (`JSON` is stateless, `Net::HTTP` has no resend hook) and both are stated as - conditional obligations on any future adapter whose library has them, per the specification's own - per-transport scoping. + near-vacuous for the MVP adapters (the JSON codec accepts no caller-supplied engine and builds a private + `::JSON::Coder` per instance; `Net::HTTP`'s one resend path, its default-on `#max_retries`, is switched off by + the adapter) and both are stated as conditional obligations on any future + adapter whose library has them, per the specification's own per-transport scoping. [Amended 2026-09-25, `C6` + of `docs/deviations.md`: the parenthetical read "`Net::HTTP` has no resend hook". `Net::HTTP#max_retries` + defaults to 1 and re-runs `req.exec`, re-writing the body, for an idempotent method; as built, + `Dexpace::Transport::NetHTTP::Adapter` sets `max_retries = 0` + (`gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/adapter.rb:154`), so no native resend + re-reads a body. Amended the same day, `C23`: the JSON half read "`JSON` is stateless", written against json + 2.9.1; at the 2.19.9 floor, phase 7a's `P7-4`.] 19. **The retry-unification sanction is stated twice with different scopes.** `09-retry-and-resilience.md` binds "a port that unifies **the stacks**"; `08-execution-pipelines.md` binds "a port unifying **retry entry points**." A port that unified only the entry points would escape the first and be caught by the second, which is diff --git a/docs/sdk-design-ruby/12-appendix-requirement-coverage-index.md b/docs/sdk-design-ruby/12-appendix-requirement-coverage-index.md index 79dd380..accab30 100644 --- a/docs/sdk-design-ruby/12-appendix-requirement-coverage-index.md +++ b/docs/sdk-design-ruby/12-appendix-requirement-coverage-index.md @@ -3,15 +3,28 @@ All 19 prefixes and 645 requirements, with the sections addressing each family and, by ID, everything the port does not implement as written. Three kinds of entry appear, and they are not interchangeable: -- **Not satisfied** — a MUST whose antecedent this port meets and whose obligation it does not. There are **two**: - **ASYNC-3** and **PIPE-33**'s interrupt clause, both consequences of §8.3's prohibition, both argued in §10.5. - Nothing else in this document should be read as claiming less than full MUST-level conformance. +- **Not satisfied** — a MUST whose antecedent this port meets and whose obligation it does not. There are **two** + in full: **ASYNC-3** and **PIPE-33**'s interrupt clause, both consequences of §8.3's prohibition, both argued in + §10.5. **Three more MUST clauses are not met on a stated domain**: **SERDE-27**'s no-materialisation clause in the + shipped JSON codec (§10.20), **TRANSPORT-14**'s header-name clause on the asynchronous transport (§10.21), and + **HTTP-45**'s no-pinning clause for a second fiber of one thread with no fiber scheduler (§10.22). Four §10 + entries narrow a MUST clause on an edge they state and argue — a frozen primary (§10.6), a borrowed `Net::HTTP` + (§10.26), an explicit scheme-default port (§10.33) and a bearer provider without `#fetch_async` (§10.34). Nothing + else in this document should be read as claiming less than full MUST-level conformance. [Corrected 2026-09-25: this entry + counted two and said nothing else fell short; the three stated-domain clauses were recorded in phase ledgers + (7a's `P7-1`, 8c's `P8-38`, 3b's `P3-27`) and in `docs/first-release.md` for the first, and reached this index + only when §10 was reconciled against the as-built tree.] - **Vacuous** — a requirement whose antecedent this port never reaches, so the guarantee holds without an - implementation. **ASYNC-4** (no interrupts are ever delivered, §10.5); **ASYNC-21**, **TRANSPORT-1**, - **TRANSPORT-2**, **TRANSPORT-8** and **TRANSPORT-18** (adapter-scoped, and no MVP adapter has the path — §3.2, - §11.18, §11.21); **NFR-8** by its own text; **CFG-34**'s boxed-versus-primitive clause (§11.15); and, at - SHOULD level, **SEAM-10** (§10.9). A vacuous item is recorded as vacuous in `dexpace-conformance`, never as - passing. + implementation. **ASYNC-4** (no interrupts are ever delivered, §10.5); **ASYNC-21**, **TRANSPORT-1** and + **TRANSPORT-18** (adapter-scoped, and no MVP adapter has the path — §3.2, §11.18, §11.21); **NFR-8** by its own + text; **CFG-34**'s boxed-versus-primitive clause (§11.15); **PAGE-15**'s wrapping clause (§10.29); and, at SHOULD + level, **SEAM-10** (§10.9) and **CFG-19**'s unwrap (§10.29). A vacuous item is recorded as vacuous in + `dexpace-conformance`, never as passing. [Amended 2026-09-25, `C6`, `C7` and `C13` of `docs/deviations.md`: + **TRANSPORT-2** left this list — `Net::HTTP#max_retries` defaults to 1 and `Async::HTTP::Client`'s `retries:` to + 3, and both adapters set 0, so it is satisfied by construction; **TRANSPORT-8** left it — `dexpace-transport-net_http` + has no internal cancellation, but `async-http` delivers `Async::Cancel` into an in-flight exchange and + `dexpace-transport-async_http` completes the future with a terminal cancellation, asserted in its own suite; and + **PAGE-15**'s wrapping clause joined it (phase 7c's `P7-1`), with **CFG-19** (phase 5a's `P5-11`).] - **Deferred** — a **SHOULD** or **MAY** the port declines for the first release, listed by ID. Deferred means not implemented, never reinterpreted. @@ -24,22 +37,22 @@ a conformance claim. | Prefix | Count | Addressed in | Deferred / notes | |---|---|---|---| | SEAM | 30 | §2.4, §3.1–§3.7, §5.3 | *Deferred:* SEAM-24 (SHOULD, cross-thread diagnostic-context propagation) beyond fiber storage, ships with `dexpace-async-async`; SEAM-28 (MAY, stable operation identifier on the projection). *Notes:* SEAM-3 and SEAM-4's byte-stream factory retired with the seam (§10.1) while SEAM-5–SEAM-9 are kept intact for the three surviving seams (§3.6); SEAM-10 vacuous (§10.9); SEAM-14/SEAM-25's close contract in §3.7; SEAM-15 (MAY) taken explicitly — a post-close send raises; SEAM-20/SEAM-21's four profiles and never-close rule in §3.4; SEAM-22's generic-type capture replaced by the witness protocol (§10.14, §7.3) and SEAM-23's SDK-owned serde failure hierarchy in §3.4 | -| HTTP | 53 | §3.1, §3.5, §3.7, §4, §6.2 | *Deferred:* HTTP-22 (MAY, name interning); HTTP-48, HTTP-49, HTTP-50 (SHOULD; ETag, Range and conditional-request helpers). *Notes:* HTTP-2/SEAM-29's constructor privacy partially unachievable and re-checked at the wire boundary (§10.10); HTTP-37/HTTP-38/HTTP-39 in §3.1; HTTP-41/HTTP-43 in §3.7; HTTP-44/HTTP-45's memoizing wrapper in §7.3 | +| HTTP | 53 | §3.1, §3.5, §3.7, §4, §6.2 | **Not satisfied on a stated domain:** HTTP-45's no-pinning clause for a second fiber of one thread with no fiber scheduler (§10.22). *Deferred:* HTTP-22 (MAY, name interning); HTTP-48, HTTP-49, HTTP-50 (SHOULD; ETag, Range and conditional-request helpers). *Notes:* HTTP-2/SEAM-29's constructor privacy partially unachievable and re-checked at the wire boundary (§10.10); HTTP-9's idempotent set public and HTTP-34's tag values `String` (§10.37); HTTP-37/HTTP-38/HTTP-39 in §3.1; HTTP-41/HTTP-43 in §3.7; HTTP-44/HTTP-45's memoizing wrapper in §7.3 | | IO | 42 | §3.1, §3.7 | *Deferred:* none. *Notes:* IO-30–IO-36 and IO-39 retired with the provider seam and its registry (§10.1) — IO-39 is entirely about the registry's lock-free reads and has no subject once the registry is gone; IO-1–IO-29 and IO-37–IO-42 implemented in full, with IO-41's idempotent close under §3.7's latch | | BODY | 37 | §3.1, §4, §5.1, §6.1, §7.1 | *Deferred:* BODY-36 (MAY, memory-mapped view) — no stdlib mmap; BODY-12 (SHOULD, platform zero-copy file transfer) lands with an `IO.copy_stream` path post-MVP. *Notes:* the five variants and both logging wrappers (BODY-2–BODY-7, BODY-10–BODY-29, BODY-31, BODY-34) in §3.1; BODY-8's ownership rule in §10.12; BODY-30's bounded error copy in §5.1, from which BODY-33's non-consuming preview falls out | | CTX | 20 | §5.4, §8.1 | *Deferred:* none. *Notes:* CTX-13's arbitrary-victim latitude (MAY) taken as-is; CTX-14's correlation bundle ships in core with OBS-25/OBS-26's sentinels and CTX-20's no-op tracer factory (§8.1), populated rather than replaced when the OTel adapter lands | -| PIPE | 40 | §5.1–§5.3 | **Not satisfied:** PIPE-33's interrupt clause (§10.5). *Deferred:* PIPE-36 (SHOULD, pillar-step stage locking) post-MVP. *Notes:* PIPE-39's convenience constructors are §5.1's preset and §5.3's two named seeding constructors; PIPE-32's asymmetry preserved and made visible (§11.16) | +| PIPE | 40 | §5.1–§5.3 | **Not satisfied:** PIPE-33's interrupt clause (§10.5). *Deferred:* PIPE-36 (SHOULD, pillar-step stage locking) post-MVP. *Notes:* PIPE-15's reuse defect detected sequentially and PIPE-5's distinct error a distinct message (§10.38); PIPE-30's fatal family read as everything outside `StandardError` (§10.23); PIPE-39's convenience constructors are §5.1's preset and §5.3's two named seeding constructors; PIPE-32's asymmetry preserved and made visible (§11.16) | | RECOV | 34 | §5.1, §5.2 | *Deferred:* RECOV-31 (MAY, per-attempt ordinal header — the same feature as RETRY-38, §11.20). *Notes:* RECOV-15's status mapping, RECOV-16's bounded replayable error copy, RECOV-32's idempotency-key step and RECOV-33's client-identity step are named steps in §5.1; RECOV-34's construction-time validation and defensive copies in §6.1 | | RETRY | 45 | §6.1, §8.3 | *Deferred:* RETRY-29 (MAY, server-driven retry override); RETRY-38 (attempt-ordinal header; tag/prose conflict recorded at §11.10); RETRY-43 (MAY, fixed-delay mode). *Notes:* both stacks ship and neither unification sanction is invoked (§6.1, §10 closing note); RETRY-36's re-mapped re-sent error body shares §5.1's bounded copy | | REDIR | 28 | §6.2 | *Deferred:* REDIR-27 (MAY, configurable target header). *Notes:* REDIR-11's marker moved to the per-hop cursor (§10.15) | | AUTH | 38 | §6.3 | *Deferred:* none. *Notes:* AUTH-9's non-blank validation, AUTH-23's ordered composition with a defensive copy, AUTH-25's proxy-flag header selection and AUTH-35's provider-result rejection are all in §6.3; AUTH-38's async delivery is §3.3's failure channel | -| PAGE | 36 | §7.1 | *Deferred:* none. *Notes:* PAGE-35 (SHOULD) is conditional on offering a mutable paging-options object; the port offers an immutable value instead, so the clause is vacuous rather than declined | +| PAGE | 36 | §7.1 | *Deferred:* none. *Notes:* PAGE-35 (SHOULD) is conditional on offering a mutable paging-options object; the port offers an immutable value instead, so the clause is vacuous rather than declined; PAGE-15's wrapping clause is vacuous by a false antecedent (`7c P7-1`, §10.29) [amended 2026-09-25, `C13` of `docs/deviations.md`: the row recorded one vacuity fewer than `dexpace-conformance` does]; PAGE-19's same-document targets and PAGE-16's extractor obligation (§10.35) | | SSE | 41 | §7.2 | *Deferred:* SSE-41 (MAY, reactive-adapter error and lifecycle latitude) — scoped to a reactive adapter and none ships; no strict-WHATWG mode (§11.17). *Notes:* SSE-40's lazy single-pass view ships (§7.2), as do SSE-20's defensive copy, SSE-21/SSE-22's value semantics and SSE-39's pull-based delivery | -| SERDE | 30 | §3.4, §7.3 | *Deferred:* none. *Notes:* SERDE-24 (ISO-8601 date form) and SERDE-25 (fresh-config factory) implemented in §3.4, SERDE-23's tolerant decode and SERDE-30's sentinel string forms likewise; SERDE-26 near-vacuous for a stateless `JSON` (§11.18) | +| SERDE | 30 | §3.4, §7.3 | **Not satisfied:** SERDE-27's no-materialisation clause in the shipped JSON codec, adapter-local (§10.20). *Deferred:* none. *Notes:* SERDE-24 (ISO-8601 date form, round-trip on the microsecond domain, §10.36) and SERDE-25 (fresh-config factory) implemented in §3.4, SERDE-23's tolerant decode and SERDE-30's sentinel string forms likewise; SERDE-26 satisfied by a private `::JSON::Coder` per codec instance [amended 2026-09-25, `C23` of `docs/deviations.md`: this read "near-vacuous for a stateless `JSON` (§11.18)", written against json 2.9.1] | | OBS | 40 | §8.1 | *Deferred:* OBS-32 (SHOULD, OpenTelemetry metric conventions) and OBS-37 (SHOULD, async body-capture skip) land with the OTel and async adapters. *Notes:* OBS-1's shared inert event, OBS-3–OBS-9, OBS-39 and OBS-40's throttled diagnostic are the event object of §8.1; OBS-19 (SHOULD) is vacuous for `Net::HTTP`, which raises on an unencodable header rather than dropping it, and binds any adapter that drops | -| CFG | 38 | §8.2, §8.3 | *Deferred:* none. *Notes:* CFG-20 (SHOULD, interruptible-task future) reshaped as the pivot (§3.3) with CFG-21's discard-path close honoured (§3.3, §3.7); CFG-34's boxed-versus-primitive array clause inapplicable (§11.15) | -| TRANSPORT | 30 | §3.2, §3.7, §6.2, §9.3 | *Deferred:* TRANSPORT-28 (SHOULD, zero-copy file body) and TRANSPORT-30 (SHOULD, undiscoverable proxy features) are per-adapter and post-MVP. *Notes:* TRANSPORT-1, TRANSPORT-2, TRANSPORT-8 and TRANSPORT-18 are adapter-scoped and vacuous for `Net::HTTP`, binding on any adapter whose client has those paths (§11.18, §11.21); TRANSPORT-22's adaptation-failure close is one of §3.7's `close_quietly` paths | -| ASYNC | 22 | §3.3, §3.7, §5.3, §8.1 | **Not satisfied:** ASYNC-3 (§10.5). *Vacuous:* ASYNC-4 (§10.5); ASYNC-21, adapter-scoped with no reactive adapter shipping, though its backpressure property is implemented on the pull-based path anyway (SSE-39, §7.2). *Deferred:* none. *Notes:* ASYNC-15–ASYNC-17's lifecycle in §3.7 | +| CFG | 38 | §8.2, §8.3 | *Deferred:* none. *Notes:* CFG-20 (SHOULD, interruptible-task future) reshaped as the pivot (§3.3) with CFG-21's discard-path close honoured (§3.3, §3.7); CFG-34's boxed-versus-primitive array clause inapplicable (§11.15); CFG-18's delay settles with `true` and refuses without a scheduler (§10.28); CFG-19 vacuous (§10.29) | +| TRANSPORT | 30 | §3.2, §3.7, §6.2, §9.3 | **Not satisfied on a stated domain:** TRANSPORT-14's header-name clause on `dexpace-transport-async_http`, with TRANSPORT-27's (SHOULD) `Content-Length` half, both waived by ID in that gem's conformance run (§10.21). *Deferred:* TRANSPORT-28 (SHOULD, zero-copy file body) is per-adapter and post-MVP. *Notes:* TRANSPORT-1 and TRANSPORT-18 are adapter-scoped and vacuous for both MVP adapters, binding on any adapter whose client has those paths (§11.18, §11.21); TRANSPORT-2 is satisfied by construction — both adapters set their client's native retry count to 0; TRANSPORT-8 is vacuous for `Net::HTTP` and satisfied on `dexpace-transport-async_http`; TRANSPORT-30 ships on `dexpace-transport-net_http` (its proxy-limitation event); TRANSPORT-5's per-call override is refused on a borrowed `Net::HTTP` (§10.26); TRANSPORT-22's adaptation-failure close is one of §3.7's `close_quietly` paths. [Amended 2026-09-25, `C6` and `C7` of `docs/deviations.md`: the row listed TRANSPORT-2 and TRANSPORT-8 as vacuous and TRANSPORT-14 as satisfied without qualification; corrected the same day: it listed TRANSPORT-30 as deferred, which phase 8a built] | +| ASYNC | 22 | §3.3, §3.7, §5.3, §8.1 | **Not satisfied:** ASYNC-3 (§10.5). *Vacuous:* ASYNC-4 (§10.5); ASYNC-21, adapter-scoped with no reactive adapter shipping, though its backpressure property is implemented on the pull-based path anyway (SSE-39, §7.2). *Deferred:* none. *Notes:* ASYNC-15–ASYNC-17's lifecycle in §3.7, the close bounded rather than cancellable (§10.17); ASYNC-18's delay holds one timer thread per pool (§10.27) | | XCUT | 24 | §3.3, §3.7, §4, §5.2, §6, §8.1, §8.3 | *Deferred:* none. *Notes:* XCUT-6's open retryability capability and XCUT-8's non-error refusal in §6.1 and §5.1; XCUT-9's cycle-safe cause walk in §5.2; XCUT-13 and XCUT-22 in §3.7 | | NFR | 17 | §2, §9 | *Deferred:* none. *Notes:* NFR-8 vacuous by its own text and NFR-9 with it, both retargeted (§10.19); NFR-11 mechanised as §9's RBS surface scan; NFR-16 (SHOULD, signing) enforced on the release path only | @@ -47,9 +60,14 @@ a conformance claim. PAGE 36, SSE 41, SERDE 30, OBS 40, CFG 38, TRANSPORT 30, ASYNC 22, XCUT 24, NFR 17 (278). **MUST-level summary.** Of the 531 MUST and 1 MUST NOT requirements, **two are not satisfied** — ASYNC-3 and -PIPE-33's interrupt clause — and **eight hold vacuously** for the reasons stated above: ASYNC-4, ASYNC-21, -TRANSPORT-1, TRANSPORT-2, TRANSPORT-8, TRANSPORT-18, NFR-8, and CFG-34's boxed-array clause. Six more — +PIPE-33's interrupt clause — **three more are not met on a stated domain** — SERDE-27's no-materialisation clause, +TRANSPORT-14 on the asynchronous transport and HTTP-45's no-pinning clause for a same-thread fiber without a +scheduler — and **seven hold vacuously** for the reasons stated above: ASYNC-4, ASYNC-21, TRANSPORT-1, +TRANSPORT-18, NFR-8, CFG-34's boxed-array clause and PAGE-15's wrapping clause. [Corrected 2026-09-25: the summary +read "eight hold vacuously … TRANSPORT-2, TRANSPORT-8" and counted no stated-domain gap; see the two list entries +at the head of this appendix.] Six more — SEAM-3, SEAM-4 and IO-30–IO-33 — are the pluggability apparatus of the one seam this port retires, argued clause-by-clause in §10.1 rather than implemented; SEAM-5–SEAM-9 survive intact because three other seams still need them (§3.6). Every remaining MUST is implemented as written or covered by a deviation catalogued in §10, and -no deviation there narrows a MUST-level correctness guarantee except the two named at the head of this appendix. +no deviation there narrows a MUST-level correctness guarantee except the five named at the head of this appendix +and the four stated edges named beside them.