Skip to content

Phase 5a: configuration and the clock — the configuration layer - #63

Merged
Wahbeh-Mohammad merged 6 commits into
mainfrom
18-phase-5a-configuration
Sep 18, 2026
Merged

Wahbeh-Mohammad merged 6 commits into
mainfrom
18-phase-5a-configuration

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Part of #18. First PR of the nine-PR phase-5 stack — phase 5a's code. Its tests are the next PR up and the phase record the one after; phase 5c's three PRs (#20) sit on 5a's docs tip and phase 5b's three (#19) on 5c's.

Base note. This stack was cut from main at 993c439 (phase 4 complete). Phase 5a and phase 5c were built in parallel off that commit and each was approved by independent review; 5c's stack was then rebased onto 5a's docs tip and reconciled (the entry file's two phase-5 blocks in sub-phase order, the manifest regenerated on the combined tree, CLAUDE.md/READMEs/roadmap counts re-derived), and 5b was built on the combined tip. Merge bottom-up; each docs PR is the tip at which that sub-phase's documentation is consistent.

What lands

The configuration layer and the clock in dexpace-core — 51 files, +2,552 / −77: the four-tier configuration chain every later limit reads from, the injectable time seam every later wait routes through, the environment-driven proxy model, and §16.6's five free-standing utilities. All thirty-eight CFG IDs. The only plain requires added are time and uri (both allowlisted; rbconfig avoided, so the require allowlist does not grow); the gemspec keeps zero add_dependency lines; the only blocking primitive anywhere is ::Thread::Queue#pop(timeout:) at three sites; every Regexp is built with timeout:.

  • Dexpace::Configuration with ::Builder, ::Keys (seven CFG-14 names, including the retry-attempt cap RETRY-12 reads) and ::Sources (ENVIRONMENT, NONE, .from_hash) — four tiers with a substituted third source (design §10.16): call-site override → ENV → configure defaults → caller default, preserved even where it inverts Ruby convention because CFG-1 is normative. The CFG-3 normalised accessor and the CFG-4 raw exact-name accessor stay distinct; CFG-2's emptiness is the environment tier's alone (a chain-wide reading was a guard run red); the parsers (CFG-5–CFG-7, three duration grammars, a bare number as milliseconds) and the deep-value comparison (CFG-33/CFG-34: NaN folds equal, signed zeros unequal with a matching hash, cycle-safe; the container clause inapplicable per §11.15) are private_constants asserted at their call sites. Builder.new validates every seed and both seams through the setters' own guards (from the review).
  • Dexpace.configure / .configuration / .reset_config! (CFG-8, CFG-13) — one frozen reference swapped under a ::Thread::Mutex, read lock-free; the builder and the block run outside the lock.
  • Dexpace::Clock and Clock::SYSTEM (CFG-15–CFG-17) — #now, #monotonic, #sleep(duration, cancellation:) as a per-call Queue#pop(timeout:) woken by the token's on_cancel push, the token re-checked after the wake (CFG-17's re-assertion is the token's own state), never Kernel#sleep (design §10.17). Dexpace::Async.delay (CFG-18) — scheduler-conditional: unmounts the fiber under a registered Fiber.scheduler, and raises SeamError without one rather than claiming not to block a thread while blocking one (P5-9; settles with true, P5-52).
  • The pivot's deadline: keyword, picked up from phase 2 — Future#value/#wait and Completer#await gain deadline: and clock: (the positional cancellation kept; NFR-4 widening only): a timed gate pop inside Completer#await ending in phase 2's own request_cancel(:deadline_expired), never a raise from #await and never Cancellation.any composition; validation runs before the settled short-circuit (from the review).
  • Dexpace::Proxy, ::Type (a closed set in Pipeline::Stage's shape — .of the only lookup, #with refuses), ::HostPattern (CFG-23: \A…\z, escaped, IGNORECASE, compiled once at construction) and the private_constant ProxyResolution behind Proxy.resolve (CFG-24–CFG-28): never raises — every malformed input yields nil plus one Kernel#warn (phase 2's P2-6 precedent; 5b adds an event beside each), an out-of-range port likewise, NO_PROXY/http.nonProxyHosts with CFG-26's escaped separators, a blank HTTPS_PROXY from any tier no longer masking HTTP_PROXY (from the review). Proxy#to_s/#inspect mask both credentials — CFG-22's "never emit username/password in cleartext" (review round 0's should-fix; the plan's fence printed the username).
  • §16.6's utilities — Dexpace::HTTPDate (CFG-29: Time#httpdate formats; CFG-30/CFG-31: an owned anchored grammar accepting GMT/UTC/+0000/+00:00, tolerating a wrong weekday, refusing blank input and a missing comma, and refusing what Time.utc would silently normalise — 31 Nov, 29 Feb 1995, hour 24, second 60 — P5-54; Time.parse stays cop-banned); Dexpace::UUID (CFG-32: v4 from a per-thread ::Random in Thread.current[], never SecureRandom — XCUT-21's path stays separate); Dexpace::Retryability.retryable_status? (CFG-35's status half as XCUT-5's single shared classifier — 408, 429, 5xx except 501/505; the throwable half is phase 6a Task 3's and no stub for it exists); Dexpace::BuildInfo (CFG-36: every token non-blank, RUBY_ENGINE/RUBY_ENGINE_VERSION/RUBY_PLATFORM, no rbconfig, loads under --disable-gems).
  • The two wirings earlier phases postponed here, each a decision recorded rather than a silent edit: ContextStore.default is built on its first call under one ::Thread::Mutex with the cap read from the chain (Keys::MAX_TRACKED_CONTEXTS, non-positive → MAX_TRACKED_CONTEXTS), the chain read outside the lock and a lock-free fast path in front, so no caller seam runs under the mutex and a promotion after the first takes no lock — phase 4a's load-time assignment and its fresh-process test are replaced on this branch (option (b), P5-55; an unsynchronised ||= gives sixteen stores to sixteen first callers, a guard run red on both interpreters). Dexpace::IO.max_materialized_bytes(configuration = Dexpace.configuration) is read per call by the five value-readers of MAX_MATERIALIZED_BYTES (TypedReads#guard_materialization!, Body.clamp_cap, StreamBody#replayable?, BufferBody#==), so Dexpace.configure governs the live ceiling and a refusal names the limit that applied; the constant stays the default and no ceiling: keyword exists anywhere (option B, P5-56 — a "configurable" ceiling no reader consults is not configurable).
  • sig/ mirrors lib/ one file per file — seventeen new mirrors, the three private_constants carrying hooks.rbs's comment (P5-57); _Clock, _ConfigSource and _RetryableStatus declared; sig/dexpace.rbs unchanged. The surface manifest regenerated once (730 → 814, all 84 rows read against the object model; no row for a private constant or helper). Three gate-read test files ride here because the code change invalidates them: phase 4a's context_store_test.rb fresh-process case, phase 2's seam_surface_test.rb stdlib-feature count (three → four, time), and the smoke suite's constant list.

Decisions taken in the open, against the plan's text

Ledger rows P5-51–P5-58 and the checklist's "Deviations from the plan" (26 items) itemise them: Configuration::Builder in its own file (P5-51); Async.delay settling with true (P5-52); HostPattern a one-member Data with the compiled pattern an ivar (P5-53); HTTPDate's component round-trip (P5-54); the context-store cap's construction shape (P5-55); the ceiling readers moved (P5-56); the private constants' sig/ mirrors and three private helper modules (P5-57); the doubles renamed and top-level (P5-58 — the plan's FakeSource and ProbeScheduler collide with phase 3a's and phase 2's files). Proxy::Type takes P4-56's closed-set shape rather than phase 1's Status shape.

Layering

Each tip of the stack is green under every gate on its own tree. This branch is green on the SimpleCov floor too — 93.45% on 4.0.6 (93.42% on 3.2.11; 1,553 runs, 0 failures). The tests PR takes the same tree to 99.97%.

Verification

  • Independent reviewer, one fix round: nine round-0 findings (1 blocking, 1 should-fix, 7 nits), each verified resolved individually on re-review with its own reproduction and a regression mutation, then a full second pass — approve, 0 blocking / 0 should_fix / 2 nits.
  • Mutations. 36 run red by the implementer (seven on 3.2.11 too) and 47 / 53 applied by the two reviewers themselves, every one caught (the pop-without-timeout mutant hangs by construction and was killed): the tier order swapped; normalisation dropped; downcase(:turkic) (the cop); a bare number as seconds; the builder aliasing the caller's hash; the slot assigned outside the mutex; the wait as Kernel.sleep; the token not re-asserted; #monotonic via Time.now; the deadline raising a bare CancelledError; CFG-19 re-raising a copy; the glob unanchored, unescaped, case-sensitive; the resolver raising on garbage; port 65536 accepted; the escape lookbehind dropped; the zone group reduced to GMT; blank input answered with the epoch; SecureRandom.uuid; the version and variant nibbles wrong; NaN by == with two distinct objects; 0.0 == -0.0; NaN hashed unfolded; 501 retryable; 408 not; a blank BuildInfo token; a bare ArgumentError from a guard; an unsynchronised ||= on the store; a Regexp without timeout:; a ceiling reader back on the constant; chain-wide emptiness.
  • By experiment on both interpreters: the four tiers removed one at a time; Clock::SYSTEM.sleep(5, cancellation:) cancelled from another thread returning in 0.050 s; the bounded wait over an unsettled Completer with a FakeClock and with the real one; Async.delay under the parking scheduler (#block fires, #kernel_sleep never); the proxy resolver over hand-built sources with exactly one warning per malformed input; all four zone tokens parsing to one instant; 10,000 distinct v4 UUIDs, 8 threads × 1,000, three distinct Random objects across a fiber and a thread; DeepValue through module Dexpace (NaN pair equal and hash-equal, [1] != [1.0], a cycle returning); BuildInfo loading under --disable-gems on all four Rubies; 16 threads configuring and reading with no torn observation; proxy/type and configuration/keys loaded first raising superclass mismatch, as the plan says.

Known follow-ups from the review (not blocking)

  • R1-2 — every Dexpace.configure { |c| c.property(...) } over the live slot wraps the inherited property seam in one more closure, so repeated reconfiguration without a reset_config! grows the lookup chain without bound (measured 1157× on raw_property("missing") after 2,000 configures, every earlier table retained). Fix shape: flatten @properties over an inherited table the builder itself made into one Sources.from_hash table, composing only over a caller-installed seam. Code branch. Worth fixing before phase 6 reads the chain in a hot path.
  • R1-1 — one BuilderContractTest assertion reads the host's K through the default env seam (Builder.new(property_source: nil)); the case is about property_source: nil, so pass env_source: Sources::NONE. Tests branch.
  • Phase 3a's and 3b's own materialisation suites read the ceiling through the slot with no fake seam and would fail on a host exporting MAX_MATERIALIZED_BYTES — stated in io_ceiling_test.rb's header and the IO-9 row rather than fixed, because those suites are their phases'; a phase-10 inbound candidate.
  • A standalone require "dexpace/io/buffer" or "dexpace/context_store" now fails on Dexpace.configuration where it loaded on main — the same call-time pattern main already has elsewhere (body.rb → BytesBody, completer.rb → Future); the entry file is the load path.
  • The design's §8.2/§8.3/§10.16/§10.17 addenda and the consolidation of P5-1–P5-15 and P5-51–P5-58 into design §10 are a human's (frozen chapters); no frozen sentence is contradicted.

Phase 5a, Tasks 2 through 5: the four free-standing utilities the
configuration chapter files beside the chain.

- Dexpace::BuildInfo (CFG-36) resolves the SDK version and the runtime's
  version, vendor and OS name into frozen constants at load, each
  falling back to the non-blank "unknown", and composes IDENTITY_TOKENS
  in the fixed SDK-then-runtime order. Every component is read off a
  constant that needs no require, so the allowlist does not grow.
- Dexpace::UUID.generate (CFG-32) draws sixteen bytes from a ::Random
  kept in the fiber-local Thread.current[:dexpace_prng] -- one generator
  per execution context, none shared -- masks the version and variant
  nibbles and returns the frozen canonical form. Never SecureRandom.
- Dexpace::Retryability.retryable_status? (CFG-35's status half,
  XCUT-5's single classifier) answers for an Integer or anything
  answering #code: 408, 429 and every 5xx but 501 and 505.
- Dexpace::HTTPDate (CFG-29 to CFG-31) formats with Time#httpdate and
  parses with an owned anchored grammar, never Time.httpdate, which
  accepts the obsolete RFC 850 and asctime forms; every component is
  checked back against what Time.utc built, because Time.utc(1994, 11,
  31) is silently 1 December (P5-54). The grammar is one Regexp compiled
  once with a per-pattern timeout.

seam_surface_test.rb now counts four stdlib features: http_date.rb
requires "time" in the file that uses it.
Phase 5a, Tasks 6 through 8: the injectable time source and what waits
on it.

- Dexpace::Clock (CFG-15, CFG-16) exposes exactly the three operations
  the _Clock interface declares -- #now, #monotonic on CLOCK_MONOTONIC's
  scale, and #sleep -- with Clock::SYSTEM as the platform-backed default
  and Clock.deadline_in as the one way to compute an instant, so a
  deadline is never derived from Time.now. #sleep is a per-call
  ::Thread::Queue#pop(timeout:) that the cancellation token's #on_cancel
  hook wakes, with the token re-checked after the wake (CFG-17): never
  Kernel#sleep, which cannot be woken without Thread#raise. The hook is
  detached in an ensure.
- Future#wait, #value and Completer#await take deadline: and clock:
  beside the positional cancellation (SEAM-18, phase 2's P2-5 pick-up).
  The deadline is the same timed gate pop, and expiry settles the
  completer through request_cancel(:deadline_expired) rather than
  raising past the wait; the loop lives in the private Async::Deadline
  helper.
- Dexpace::Async.delay (CFG-18) is the scheduler-conditional
  counterpart: a Future settling with true after the duration elapses on
  the registered Fiber.scheduler, and Dexpace::SeamError without one,
  since a thread-backed fallback would violate the headline it exists
  for. It settles with true, not nil, because SEAM-16 makes a nil-valued
  Settlement unconstructible (P5-52).
Phase 5a, Tasks 9 through 13: the four-tier chain, the process-wide
slot, and the two earlier layers that now read them.

- Dexpace::Configuration (CFG-1 to CFG-12, CFG-37, CFG-38) is a frozen
  Data over the override map and the two seams. #string looks up in the
  strict order -- override, environment, normalised property, default --
  that inverts Ruby's convention on purpose (design ledger entry 16);
  CFG-2's emptiness rule applies to the environment tier alone; and
  #raw_property reads the property tier by the exact name. #integer,
  #boolean and #duration route through #string and never raise, with
  durations in Float seconds and a bare number read as milliseconds.
  Configuration::Builder is a file of its own beside its model, as phase
  1 files every builder (P5-51); an inherited seam passes through by
  reference, added properties compose over it, and the two seam
  operations are mutually exclusive on one builder. Keys declares the
  seven CFG-14 names; Sources holds ENVIRONMENT, NONE and .from_hash.
  ConfigParsers and DeepValue (CFG-33, CFG-34: NaN equals NaN and hashes
  alike, signed zeros differ, Integer and Float arrays differ, cycles
  survive) are private_constants with sig/ mirrors (P5-57).
- Dexpace.configure, .configuration and .reset_config! (CFG-13) swap one
  frozen reference under a ::Thread::Mutex and read it lock-free; the
  builder runs outside the mutex, so a block that reads the slot cannot
  deadlock it.
- ContextStore.default is built on its first call, under one mutex,
  reading Keys::MAX_TRACKED_CONTEXTS then and falling back to the
  constant on a non-positive value (P5-55). Phase 4a's load-time
  assignment could never see a Dexpace.configure at boot, and an
  unsynchronised ||= publishes one store per first caller; phase 4a's
  fresh-process case now asserts no store exists before the first call.
- Dexpace::IO.max_materialized_bytes reads Keys::MAX_MATERIALIZED_BYTES
  with the constant as the fallback, and the five readers of the
  constant -- TypedReads#guard_materialization!, Body.clamp_cap,
  StreamBody#replayable? and BufferBody#== -- read the function per call
  (P5-56), so the live configuration governs every materialisation.
Phase 5a, Tasks 14 and 15: CFG-22 through CFG-28.

- Dexpace::Proxy is a frozen Data over eight members, built through
  .build with the port validated into 0..65535 and every non-proxy entry
  a HostPattern; #to_s renders type://user:****@host:port and #inspect
  masks the password and names the challenge handler by class only.
  #bypass? is true under bypass_all or on any matching pattern.
- Proxy::Type is a closed set of three in the pipeline's Stage shape:
  .of is the only lookup and canonicalises a copy back to its constant,
  .new and .[] are private, there is no .build and #with refuses, so
  identity comparison over the constants holds.
- Proxy::HostPattern is a one-member Data over the glob with the
  compiled Regexp a private instance variable set at construction
  (P5-53): anchored \A...\z, case-insensitive, every character but * and
  ? literal, per-pattern timeout, never the process-global one.
- Proxy.resolve reads the chain through the private ProxyResolution and
  never raises on its content: the property tier first, https.* over
  http.* with the port taken from the same layer as the chosen host and
  credentials from https.* only; the environment second, HTTPS_PROXY
  then HTTP_PROXY, with an explicit port required since CFG-25 forbids
  defaulting to 80 or 443. Every malformed input yields nil after one
  Kernel#warn prefixed "[dexpace]" (P5-8), with a StandardError backstop
  behind the explicit branches. http.nonProxyHosts wins over NO_PROXY,
  an escaped separator is literal, and a bare "*" yields nil because a
  proxy no request will use is not a proxy (CFG-27). Nothing in core
  calls .resolve, which is how CFG-28's prohibition on implicit reads is
  met.

proxy.rb loads its three nested files from inside its own class body;
the entry file's phase-5a block is now complete at ten lines.
Phase 5a, Task 16's surface half. The runtime surface snapshot grows by
84 rows, 730 to 814: the public constants and methods of Configuration
and its Builder, Keys and Sources, the slot's three module methods,
Clock and Clock::SYSTEM, Async.delay, the deadline: and clock: keywords
on the future and the completer, Proxy with Type and HostPattern,
HTTPDate, UUID, Retryability, BuildInfo and IO.max_materialized_bytes.
Every new row was read against the object model; no row was removed, and
the three private_constants contribute none.

The smoke suite pins the configuration layer beside the seven before it
and preloads "time" so its "defines nothing outside Dexpace" case keeps
its meaning: http_date.rb requires the feature, which defines Date and
DateTime at the top level.
Review round 0 of the phase-5a stack. CFG-22's canonical text is "never
emit username/password in cleartext", and Proxy#to_s and #inspect
printed the username verbatim beside a masked password (R0-2): both
credentials now render as "****" when present and as nothing when
absent, so a rendering says only whether a credential is set. Four nits
on the same layer: ContextStore.default reads the chain outside its
mutex and only the `||=` runs under it, with a lock-free read of the
published reference in front, so no caller-supplied seam runs under a
non-reentrant lock and a promotion after the first pays no lock (R0-4);
Completer#await validates deadline: and clock: before the settled
short-circuit, so a settled future ignores an expired deadline and not
an invalid one (R0-5); Configuration::Builder.new routes its seed map
through #override and its seams through the setters' guard, so a nil
seed value or a non-callable seam fails fast under CFG-37 instead of
stringifying to "" (R0-6); and a blank HTTPS_PROXY override or property
no longer masks HTTP_PROXY, since CFG-2's fall-through covers only the
environment tier and a blank is not a URL (R0-8). The RBS mirrors
follow; the manifest is unchanged because every added member is
private.
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit ee20b43 into main Sep 18, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 18-phase-5a-configuration branch September 18, 2026 09:24
@Wahbeh-Mohammad Wahbeh-Mohammad mentioned this pull request Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant