Skip to content

Phase 4b: recovery-chain primitives — the recovery layer - #53

Merged
Wahbeh-Mohammad merged 3 commits into
mainfrom
15-phase-4b-recovery-primitives
Sep 16, 2026
Merged

Wahbeh-Mohammad merged 3 commits into
mainfrom
15-phase-4b-recovery-primitives

Conversation

@Wahbeh-Mohammad

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

Copy link
Copy Markdown
Contributor

Part of #15. First of three phase 4b PRs — the code. Its tests are #54 and the phase record is #55.

What lands

§8.2's resilience layer in dexpace-core — 40 files, +1,841 / −33 — and the error primitives four earlier postponements and five later phases have been waiting on. Nothing here talks to a socket, 4b adds no require of any kind beyond require_relative (the visited set is {}.compare_by_identity, never set), and the gemspec keeps zero add_dependency lines.

  • Dexpace::Suppressible — the suppressed-exception trail phase 1 postponed to phase 4, as a separate module Dexpace::Error includes and Dexpace.attach_suppressed extends onto a caller's exception (P4-12: rescue M matches a module reached through a singleton class, so the rescue root cannot be the thing extended, or rescue Dexpace::Error would start catching errors the SDK never raised). #suppressed is a frozen array replaced on every attach (P4-14); a frozen primary is a silent no-op (P4-13); the skip-self guard RETRY-34 needs; #detailed_message renders the trail so #full_message and the default printer show it. Dexpace.suppressed(error) reads a trail off anything.
  • Dexpace.each_cause (XCUT-9) — yields the error first (P4-16), tracks by reference identity through compare_by_identity — an Array truncates a chain of structurally-equal errors and a Set is defeated by a caller's hash/eql? override, both measured — stops at a #cause that raises or lies, and returns an Enumerator without a block.
  • Phase 2's two retrofits, exactly as its postponements asked. Hooks.notify attaches every later handler failure to the first and re-raises it with cause: nil; Dexpace.close_quietly(resource, onto: nil) gains the first of its two disposal routes (the diagnostic half stays phase 5b's). Three phase-2 lib files change and nothing else of phase 2's does.
  • Dexpace::Outcome with exactly two variants, Success and Failure (RECOV-1), Data + Model + validating .build, #fold requiring both branches, #response_or_nil / #error_or_nil (P4-21), and deconstruct_keys working under private_class_method :new.
  • Dexpace::OutcomeError (R6, P4-19) — a fold's own defect, re-raised by name through the orchestrator rather than demoted to a Failure a recovery step could swallow; Dexpace::ProtocolError — XCUT-4 branch (a), one class carrying #status and #response with no subclass tree (P4-20), .for raising for a non-error status and .for_or_nil returning nil (XCUT-8's two forms), a message of "HTTP 404 Not Found" with no body preview. No #retryable? and no #retryable_by_status? — XCUT-5's flag is postponed to phase 6a, Task 6, and the RECOV-15 checklist row says so.
  • Dexpace::Recovery.buffer_error_body(response) — the contract phase 3b fixed: the same object back for a non-error status or a nil body, otherwise Body.buffer_bounded with MAX_BUFFERED_ERROR_BODY_BYTES and no second constant (RECOV-16, BODY-30).
  • Dexpace::Recovery::Transform — R8's single-sourced contract 4c's generic adapter consumes: #phase, #apply(value) and the one default #call(value) = apply(value) (P4-25) so a transform installs into a chain by bare reference, beside any lambda. The three shipped transforms: IdempotencyKeyStep (RECOV-32, POST/PUT/PATCH by default, a GET untouched by identity), ClientIdentityStep (RECOV-33), ErrorMappingStep (RECOV-15, PIPE-37: a non-error response passes through by identity with its body never read; an error response is buffered, the original closed, and the factory's error raised carrying the buffered response; factory: overridable, the default a frozen private lambda over ProtocolError.for).
  • RequestChain (RECOV-3, a left-to-right fold) and ResponseChain (RECOV-4–RECOV-8: response steps on Success only, recovery steps on every outcome, in order, a throwing step becoming the Failure the next recovery step sees), both dup+freezeing their step lists (RECOV-14, P4-22); the private Ownership helper concentrating RECOV-12/RECOV-13's asymmetry — a response in hand is closed exactly once on a throw, through close_quietly(response, onto: error) so a close failure lands on the primary's trail instead of replacing it, and never on a deliberate substitute outcome. "Exactly once" is phase 2's Closeable latch, not bookkeeping.
  • Orchestrator (RECOV-2, RECOV-10, RECOV-11) — a Dexpace::Transport duck type over #call(request, options, cancellation): one rescue region around the request fold, the transport call and the response fold, so a throwing request step reaches after-error handling; the fatal family and OutcomeError pass through untouched with no trail; every unwrap is raise error, cause: nil, because a bare raise error from inside a caller's rescue assigns that caller's unrelated $! as #cause on all three interpreters. RECOV-11 is satisfied structurally: phase 2's token has no clearable flag, so converting a CancelledError into a Failure cannot swallow the signal, and the suite asserts that on the token (P4-17).
  • sig/ mirrors lib/ one file per file — sixteen mirrors, ownership.rbs among them with hooks.rbs's comment on why a private_constant still needs one for the strict core target; the module functions declared beside their defining files, sig/dexpace.rbs unchanged. YARD 100% (418 methods). The surface manifest regenerated once (515 → 586 rows, all 71 re-derived by hand from the object model — Data readers and singletons included — and nothing private leaked). The core smoke suite's constant list is on this branch because the code change invalidates it.

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

Twenty-seven are itemised in the checklist's "Deviations from the plan" and ten carry ledger rows P4-40–P4-49. The ones a reader should know: Suppressible#detailed_message(options = nil) takes one optional positional Hash, not **kwargs — Dexpace/NoKeywordSplat forbids the splat on a public lib method, and the forwarding was measured identical for the default printer, full_message and did_you_mean on 3.2.11, 3.4.10 and 4.0.6 (P4-40); Orchestrator#call takes three required positionals, matching phase 2's bridges and Transport.conforms?(arity: 3), not the plan's two optionals; Transform's RBS is the generic [T] (T) -> T because Steep could not type the overloaded forward; boundary validation the design did not name (a step's non-Request/Response return, a factory's non-Exception, a transport's non-Response — P4-45); Ownership closes through close_quietly(onto:) and rescues StandardError only, so the design's rescue ::Exception; raise arm is its behaviourally equivalent absence (P4-47, proven by experiment and by mutation).

Layering

Each tip of the stack is green under every gate on its own tree. This branch is green on the SimpleCov floor too — 94.64% on 4.0.6 (1,154 runs, 0 failures), because the phase-2 suites reach the retrofits and the smoke suite loads the layer. The tests PR takes the same tree to 99.97% (3,396 / 3,397; the one uncovered line is phase 2's registry race-only branch).

Verification

  • Independent reviewer, approve on round 0 — 0 blocking, 0 should-fix, 4 nits (below). It re-ran all seventeen gates individually here on 4.0.6, bundle exec rake on the two upper tips, the matrix set on 3.2.11 (both this tip and the tests tip) and 3.4.10, and the probe.
  • 43 single-edit mutations applied by the reviewer (39 by the implementer), every one caught: the bare raise error on the unwrap and the Array/Set visited-set spellings red on 3.2.11 as well as 4.0.6 (a raise-built pair is not == there, which is why the fixture constructs and never raises); OutcomeError and LoadError rescued into a Failure; response steps run on a Failure; the fold interleaved; Ownership closing zero and two times; the step lists aliased; ErrorMappingStep reading a 200's body; a literal 1_048_576 for the constant.
  • By experiment on both interpreters: RECOV-10 with a constructed versus a raised fixture (the surfaced error is the same object with #cause nil only for the constructed one); the trail on a bare ::IOError (is_a?(Suppressible) true, is_a?(Dexpace::Error) false, rescue Dexpace::Error does not catch it, a frozen primary returns unchanged); the three cycle fixtures under timeout; three raising on_cancel handlers; RECOV-11 asserted on the token; the ownership counts over RecordingBody; a 2 MiB error body truncated to exactly the constant with the original released once; case/in under private new.
  • grep over the added lib: zero plain requires, zero synchronize/Mutex/Thread, one rescue region in the orchestrator, every unwrap cause: nil, Dexpace::ArgumentError never defined, the sixth cop 0 offenses; honest RuboCop 261 files, no offenses.

Known follow-ups from the review (not blocking)

  • R0-2 — no literal rescue ::Exception; raise arm in the orchestrator or Ownership; the tree's rescue OutcomeError; raise; rescue ::StandardError is equivalent (fatal family and OutcomeError pass through, mutations widening either region go red). Recorded as P4-47; add the literal arm only if the design's R6 shape is wanted verbatim.
  • R0-4 — the three transforms, both chains and the orchestrator are not freezed after .build; their members are frozen and they hold no per-call state, which is what RECOV-14 asks, and no ID or design sentence requires the instance frozen. A cheap hardening if wanted.
  • Two committed phase-2 sentences are now false and are the manager's to rewrite, not this stack's: phase 2's design paragraph "Every handler runs, whatever an earlier one did" / its plan's "drops the rest" bullet, and the four places naming Dexpace::Error#suppressed as the carrier (it is Dexpace::Suppressible). Design §10 item 6 and §5.2 say the same and are frozen — routed to docs/first-release.md's C1–C13 blocker as C14 on Phase 4b: recovery-chain primitives — documentation and phase record #55.
  • The Body.stream-over-a-closed-stream ::IOError from Phase 3b: body lifecycle — the body layer #50 and the C14 placement are noted on Phase 4b: recovery-chain primitives — documentation and phase record #55.

Phase 4b (issue #15), first of three. The suppressed-exception trail
phase 1 postponed to phase 4 lands as Dexpace::Suppressible, a module
Dexpace::Error includes and Dexpace.attach_suppressed extends onto a
caller's exception -- separate from the rescue root because `rescue M`
matches a module reached through a singleton class, so extending a
third-party IOError with Dexpace::Error would widen what
`rescue Dexpace::Error` catches (P4-12). The trail is a frozen array
replaced on every attach (P4-14), rendered through #detailed_message and
never #full_message (R5), read off anything with Dexpace.suppressed
(P4-15); the writer skips self by identity (RETRY-34) and no-ops on a
frozen primary (P4-13). #detailed_message takes one optional positional
Hash, Model#with's spelling, because Dexpace/NoKeywordSplat forbids a
`**` rest parameter on a public library method (P4-40); measured on
3.2.11, 3.4.10 and 4.0.6 that the default printer's, full_message's
and did_you_mean's paths all render the same.

Dexpace.each_cause is XCUT-9's one cause walk: the error first (P4-16),
then each #cause, visited objects tracked in a Hash built with
compare_by_identity -- never an Array, whose #include? uses the
structural Exception#==, and never a Set, which a caller's hash/eql?
override defeats. A #cause that raises, or returns a non-Exception, ends
the chain rather than failing the classification (P4-48).

Phase 2's two postponements: Hooks.notify attaches every later handler
failure to the first through attach_suppressed and re-raises with
`cause: nil`, since it re-raises an error it has been carrying and a
bare raise would hand it the caller's in-flight $! as a cause;
Dexpace.close_quietly gains `onto:`, validated at entry so a bad
argument is refused before the resource is touched -- the first of §3.7's
two disposal routes; the second is phase 5b's. suppressible.rb requires
nothing and error.rb requires it, in that order and never the reverse.
Phase 4b (issue #15), second of three: §8.2's resilience layer.
Dexpace::Outcome is the closed sum type, a module both variants include
and nothing else; Outcome::Success and Outcome::Failure are frozen Data
values with a private new, a validating initialize and .build, carrying
RECOV-1's derivable surface exactly -- the two predicates, the two
-or-nil readers (the requirement's own nil, P4-21) and a fold that
invokes one branch at most once and requires both (P4-44).

Dexpace::ProtocolError is XCUT-4's branch (a): one class carrying the
buffered response and its status, no per-status tree (P4-20), a message
that names the status and never the body, and XCUT-8's two factory
forms -- .for raising an argument error for a non-error status and
.for_or_nil returning nil. Its retryability flag is phase 6a's (Task 6).
Dexpace::OutcomeError is R6's named defect: a StandardError the
orchestrator and the chains re-raise by name rather than convert,
because NoMatchingPatternError is inside StandardError and a demoted
defect is one a recovery step could swallow (P4-19).

Dexpace::Recovery holds buffer_error_body -- phase 3b's contract
implemented against its one bound and no second -- and the layer:
Transform, the pure #apply(value) contract tagged by #phase with the
module's one default #call forwarding to #apply so a transform is a
chain step with no adapter (R8, P4-25); the three shipped transforms
IdempotencyKeyStep (RECOV-32), ClientIdentityStep (RECOV-33) and
ErrorMappingStep (RECOV-15, RECOV-16, PIPE-37's untouched non-error
pass); RequestChain, RECOV-3's left-to-right fold that is deliberately
not total; ResponseChain, every response step first on a Success only
and every recovery step on every outcome, always (RECOV-4..RECOV-8),
never raising inside StandardError with P4-19's three stated exceptions;
the private Ownership helper, the one place RECOV-12's close-on-throw
and RECOV-13's no-close-on-return live, closing through
close_quietly(onto:) so a close failure rides the primary's trail
(P4-47); and Orchestrator, RECOV-2's one rescue region around the
request chain and the transport, itself a transport by phase 2's duck
type, whose RECOV-10 unwrap re-raises the terminal Failure's error
unchanged with `cause: nil`. Both chains dup and freeze their lists at
construction (RECOV-14, P4-22). Every step's return is checked at the
boundary it crosses (P4-45), and a pass-through keeps the outcome by
identity (P4-46). Every sig/ mirror declares every method for the strict
core Steep target, ownership.rbs included (P4-41).
Phase 4b (issue #15), third of three. `rake surface:regenerate`, run
once and read row by row against the design's object model before it
was accepted: 71 rows, 515 to 586 -- the three module functions on
Dexpace itself, the two variants with their Data readers, the two
errors, the Recovery namespace with its function, the transform contract
(whose #call is one row on the module, inherited by the three steps),
the three steps, the two chains and the orchestrator. No row for
Recovery::Ownership, for any private constant or for a private
singleton. The core smoke suite gains RECOVERY_LAYER -- the five
constants the entry file now adds under Dexpace -- and asserts the
layer resolves from `require "dexpace"` alone with Ownership private.
@Wahbeh-Mohammad
Wahbeh-Mohammad added this pull request to stack #56 September 16, 2026 15:29
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 93565b5 into main Sep 16, 2026
5 checks passed
Wahbeh-Mohammad added a commit that referenced this pull request Sep 16, 2026
Phase 4a and 4b ran as parallel stacks off main, and both numbered
their first as-built ledger row P4-40 -- 4a's one row (_ContextHost)
and 4b's ten (P4-40-P4-49). 4b merged first (#53-#55), so its numbers
stand on main; 4a's row moves to P4-60, with P4-50-P4-59 reserved for
phase 4c, which builds on 4b's tip. The design addendum, the checklist
and the roadmap's status note say what the row was called while the
lanes overlapped and why it moved, so the history stays readable.
Wahbeh-Mohammad added a commit that referenced this pull request Sep 16, 2026
* feat: the execution context -- three flavours, the store, the bundle

Phase 4a (issue #14). One module, Dexpace::Context, that the three
flat Data flavours include: CTX-1's one-way chain is DispatchContext
-> RequestContext -> ExchangeContext, with #promote_to_request and
exchange stage terminal by the absence of a third (P4-1). Each
promotion builds the successor through its .build, carries the bundle,
the call key and the store forward by identity, adds exactly one
artefact, and registers the successor with store.set -- the first
store entry a chain ever has, because construction registers nothing
(CTX-2, CTX-3, CTX-17). #close is store.release(self): a frozen Data
cannot carry a latch, and CTX-9's identity-conditional eviction makes
it idempotent through the store instead (P4-4). CTX-16's operation
name is nil or a non-empty frozen String, carried forward unchanged,
advisory only.

Dexpace::CallKey (private_constant) mints CTX-4's key, a frozen
"traceId:spanId:n" with one process-wide counter under one
::Thread::Mutex, so two default-constructed contexts are never ==
and a pinned call_key: is the one way to make them so (CTX-5, CTX-6).
Dexpace::ContextStore HAS the private_constant Dexpace::BoundedMap --
the one bounded-keyed-map implementation XCUT-14 and AUTH-19 will
share, a strong Hash behind one mutex with XCUT-14's drain loop inside
the same synchronize as the insert -- and exposes CTX-8's #set and
after the mutex is released), CTX-18's #[] and the identity-checked
assigned .default. The cap must be a positive Integer, since a
negative one would spin the drain forever.

Dexpace::Instrumentation is roadmap obligation 1 discharged: Bundle,
a frozen Data of eight members with #valid? derived (P4-6) and NONE
carrying OBS-26's sentinels; TraceIdFlavour, a closed set of NONE,
W3C and DATADOG with the trace-id sentinel on the flavour and the
span-id sentinel on Bundle (P4-7); and NO_SPAN, NO_TRACER and
NO_TRACER_FACTORY, three frozen singletons whose classes are private
so phase 5c can widen them without an NFR-4 diff, the factory's
(P4-8). Every sig/ mirror declares every method for the strict core
Steep target, the two private constants included as hooks.rbs is;
_ContextHost is the self-type Context needs (P4-40). The entry file
requires the twelve files in dependency order.

* feat: the eighth cop, the regenerated manifest, the smoke pins

Dexpace/NoWeakReferences is CTX-19 as a lint rule (P4-10): a constant
reference to ObjectSpace::WeakMap or ::WeakKeyMap -- qualified,
cbase-qualified, or bare inside `module ObjectSpace` -- to WeakRef, or
a `require "weakref"` in any form tools/require_scan.rb reads, is an
offense naming CTX-19 and the cap CTX-11 makes the leak backstop. A
source-text cop, because RuboCop parses and never evaluates, so the
WeakKeyMap rows parse at TargetRubyVersion 3.2 where the constant does
not exist. It is the eighth custom cop, not the plan's seventh:
CLAUDE.md, quality-gates.md, phase 2's checklist and cops_test.rb all
count Dexpace/NoKeywordSplat, which phase 0's plan says is uncounted.
Its cases live in a nested class of cops_test.rb under the 100-line
cap, fourteen rejected and fifteen accepted on RuboCop 1.91.0; the
.rubocop.yml entry scopes it to every gem's lib/, since the require
allowlist covers core alone.

The runtime surface manifest of dexpace-core grows from 515 to 580
lines through `rake surface:regenerate`, once, and every one of the
65 added rows was read against the design's object model: the seven
flat constants and the instrumentation subsystem, their .build and
.of and .default singletons, the Data readers of the five value types
(which the shipped walker holds, closing the plan's finding against
phase 0), Bundle's #valid? and #remote?, TraceIdFlavour's two
predicates and four constants, ContextStore's five methods and its
cap, and the three no-op singletons typed by their private classes.
Nothing removed; no private_constant present. Context's construction
validation is a private instance method rather than the plan's public
Context.validate!, so the module's public surface is the one method
the design gives it. The core smoke suite's constant list gains the
seven public constants and asserts BoundedMap and CallKey are as
unreachable as Hooks. Both are files a gate reads at run time, so
they travel with the code.

* fix: two stale comments -- the sig's validate! and the cop's spellings

Review round 0, R0-4 and R0-5. dispatch_context.rbs still named the
plan's public Context.validate!, which the build made the private
instance method #validate_context! (checklist deviation 5); the comment
now names what lib defines. The cop's head comment claimed to match
`require "weakref"` "in the forms tools/require_scan.rb reads", but the
scanner's loader list includes autoload and the cop's matcher does not:
`autoload :WeakRef, "weakref"` produces no offense on its own line. The
comment now states the three require spellings the matcher reads and
why the autoload form needs no row -- it names the constant as a Symbol,
and the reference that later triggers it is the one on_const flags,
verified against a scratch adapter file on 4.0.6.

* fix: a pinned call_key and an operation_name must be Strings

Context#validate_context! accepted any non-nil object as a pinned
call_key and the two flavours that carry an operation_name checked only
for "": a Symbol passed silently, keying a slot no String lookup could
find, and an Integer escaped as a NoMethodError from #empty? where every
core model raises a field-named InvalidArgumentError. The design says a
given key "must be a non-empty String" and that operation_name is nil or
a non-empty frozen String, and sig/ already types both that way.

Add the "must be a String" guard to #validate_context!, between the
required! check and the empty check, and hoist CTX-16's two-state rule
into one private Context#validate_operation_name! the RequestContext and
ExchangeContext initializers share, so the message form lives in one
place the way Model.required! keeps SEAM-29's. The .build paths pass a
frozen Symbol or Integer through Model.frozen_string untouched, so the
guard in initialize covers .build, #with and both promotions alike.
No public signature changes; context.rbs declares the private helper.

* fix: split the smoke suite's per-layer cases into a nested class

The three phase-4 lanes each added their layer's resolution case to
dexpace_test.rb; alone each kept the class under Metrics/ClassLength's
hundred lines, together they reach 116. The six "requiring dexpace
alone makes the whole <layer> resolve" cases move into a nested
DexpaceTest::Layers, the split every larger suite in this gem already
uses, so the honest RuboCop run is clean again. No case changes.

* test: the execution context's ten suites, the fake, the cop-scope pin

Ten suites mirroring the ten public lib/ files one for one, each
opening with the IDs it exercises, 112 cases in all, identical counts
on 3.2.11, 3.3.12, 3.4.10 and 4.0.6, and one fake, FakeContext, a
real in-memory Dexpace::Context of two members required explicitly by
the two suites that drive the store through a keyed occupant.

The tests a reader would otherwise write wrong, written the way the
design says: CTX-9's trap over two DispatchContexts with one pinned
key -- == and not equal?, the only way the pair exists -- so a
release by value equality fails where two minted contexts would pass
it; CTX-2's carried-forward members asserted with assert_same, never
assert_equal, so a promotion that rebuilds the bundle fails; CTX-15's
two keys minted from the SAME Bundle::NONE object; CTX-16 asserted as
a negative over a named and an unnamed promotion sharing one key;
CTX-8's race with 32 threads released from one ::Thread::Queue onto
one key, every loser's error asserted on; CTX-12's discriminating
single-threaded form, exactly one eviction per insert from cap and
the size never above it, with the aggregate count it does not use
explained in the comment; CTX-19's 1000 contexts surviving three
GC.starts with every local dropped, plus a real Request and Response
kept reachable through the store; and the Fiber[] boundary with its
setup guard. CTX-6's case asserts the counter suffix increases
strictly across flavours in build order rather than that three keys
are distinct, because a counter per flavour passed the weaker form in
the guard battery. Every thread a case starts is joined, which
DexpaceTestCase's teardown asserts; every suite is one class per
behaviour group under the 100-line cap.

test/gates/rubocop_config_test.rb pins Dexpace/NoWeakReferences's
require: line, its enablement and its every-gem scope, so a narrowing
of Include: to core alone is the one mutation of .rubocop.yml the
gate suite catches.

* test: CTX-13 via a real promotion, .default in a fresh process, the guard

Review round 0, R0-1, R0-2 and R0-3, all in context_store_test.rb.

R0-1: the CTX-13 no-refresh claim was proven only through ContextStore#set
on a FakeContext, so a #promote_to_exchange that released its source
before setting the successor -- refreshing the chain's eviction position
and leaving the slot transiently empty between two mutex acquisitions --
survived every suite. A BoundTest case now drives the policy through the
real path: cap 3, three chains at the request stage, a's promoted to
exchange, a fourth chain promoted, a still the victim and both of its
links closing false. The survivor assertion is a key list so the red
never dumps a whole context. Under the mutation: Expected ["b","c","d"]
Actual ["a","c","d"] on 4.0.6 and 3.2.11.

R0-2: a memoised .default (`@default ||= new`, load-time line deleted)
survived, because the identity case runs after some .build has already
called .default in-process. A ReachabilityTest case asks a fresh process
-- RbConfig.ruby -w -W:deprecated, `require "dexpace"` alone -- whether
the ivar is set before any call. Under the mutation: Expected "true"
Actual "false" on both interpreters.

R0-3: the Fiber[] boundary test's setup guard covered the main fiber and
a child Fiber; the design names three carriers. The guard now writes a
fiber-storage slot and a fiber-local slot on the main fiber and asserts
[:fiber_storage, nil] inside a child Fiber, a new ::Thread and an
Enumerator's internal fiber -- observability/016d9154's fact, re-run on
4.0.6 and 3.2.11 -- and resets both slots in an ensure.

Store suite 18 -> 20 runs; ten seeded runs on 4.0.6 and one on 3.2.11
green.

* test: a Symbol or Integer call_key and operation_name are refused

Four cases for review round 1's R1-1, each asserting the field-named
InvalidArgumentError message on :sym and 5 (or :GetUser and 7): the
probe includer in context_test.rb drives #validate_context! directly and
the surface case now names #validate_operation_name! as private too;
dispatch_context_test.rb covers DispatchContext.build(call_key:) and a
#with derivation; request_context_test.rb covers RequestContext.build
and the dispatch->request promotion, and asserts the failed promotion
registered nothing; exchange_context_test.rb covers the terminal
flavour's own build path. Four mutations run red on 4.0.6 and 3.2.11:
the call_key guard deleted (1 failure in each of the first two suites),
the operation_name guard deleted (1 in each of the last two), and the
helper's call removed from either flavour's initializer (2 failures in
that flavour's suite alone, the other suite staying green).

* docs: phase 4a's checklist, ledger addendum, as-built page, counts

The checklist, written from the build: twenty rows, CTX-1 to CTX-20,
every one implemented and tested, each naming the task and the suite
that proves it; what was built; the guard battery -- eighteen
single-edit mutations, seventeen caught on the first run and the
per-flavour counter caught once CTX-6's case asserted the counter's
order across flavours, with the CTX-9 trap and the WeakMap swap run
red on 3.2.11 as well; the audit groups; sixteen departures from the
plan's text, four of them phases 0-3b as built overriding the plan
(the eighth cop, the CountKeywordArgs: false already in the baseline,
the sig/ mirrors hooks.rbs sets the precedent for, the Data readers
the shipped walker already holds); the findings routed; and the two
postponed items keeping their owners.

The design's ledger gains an "As built" addendum: P4-40 for the
_ContextHost self-type interface, numbered from the tree because 4b's
design filed P4-12-P4-25 and 4c's P4-26-P4-39, and P4-3, P4-8, P4-10
and P4-11 as built. docs/sdk-documentation/execution-context.md is
the as-built page, every fence run verbatim on 4.0.6 and 3.2.11 with
identical output; architecture.md, quality-gates.md (eight cops, 129
cases), the core README, README.md and docs/README.md point at it.

CLAUDE.md's built-phases paragraph, its lib-file count (seventy-five
beside version.rb, three private constants without a test/ mirror),
its phase-directory sentence (six checklists) and its constraints list
(the no-latch close, CTX-9's equal?, the eighth cop) are rewritten
from the tree. The roadmap gains the phase 4a status note and a
thirty-eighth inbound bullet for phase 10 (phase 0's plan calls the
keyword-splat cop uncounted; every as-built document counts it), and
its execution step 5 is corrected in place, with the phase 3, 4, 5, 6
and 8 segmentation designs' restatements of it: each sub-phase returns
to main as its own code -> tests -> docs stack, which is what every
phase since 1 did and what "one phase-level pull request" said none
would. The frozen chapters' addenda and the consolidation into design
§10 are a human's, as they were for 3a and 3b.

* docs: record round 1's two guards, the three-carrier guard and the counts

Review round 0's three should_fix findings landed as test cases; this is
their record. The checklist's CTX-13 row now says the no-refresh claim is
driven through a real promotion as well as the fake, its guards table
gains rows 19 (release-then-set promotion) and 20 (memoised .default),
both red on 4.0.6 and 3.2.11, and its prose says which round found each
survivor; the Fiber scheduler audit row and deviation 13 describe the
boundary test's three-carrier guard and the new run counts (store suite
20, execution-context suites 114). The design's As-built addendum gains
one bullet for the three testing-strategy cases as built in round 1, and
the roadmap's 4a status note reads 1,268 runs and twenty mutations.

* docs: record round 2's String guards, the four mutations and the counts

Review round 1's R1-1: a pinned call_key or an operation_name was never
checked to be a String. The checklist's CTX-4 and CTX-16 rows now say
the type is enforced and where, the "What was built" paragraph names the
second private helper, guards 21-24 record the four mutations run red on
4.0.6 and 3.2.11, deviation 17 states the departure from the plan's
emptiness-only fences with its reason, and deviation 13 carries the new
per-suite and total run counts (118 execution-context cases). The
design's As-built addendum gains the round-2 bullet and the P4-11 bullet
names the helper; the roadmap's status note reads 1,272 runs,
twenty-four mutations and seventeen departures; the as-built page's
operation-name and pinned-key passages state the refusal and its
message.

* docs: renumber 4a's as-built ledger row P4-40 to P4-60

Phase 4a and 4b ran as parallel stacks off main, and both numbered
their first as-built ledger row P4-40 -- 4a's one row (_ContextHost)
and 4b's ten (P4-40-P4-49). 4b merged first (#53-#55), so its numbers
stand on main; 4a's row moves to P4-60, with P4-50-P4-59 reserved for
phase 4c, which builds on 4b's tip. The design addendum, the checklist
and the roadmap's status note say what the row was called while the
lanes overlapped and why it moved, so the history stays readable.

* docs: reconcile 4a's status note with the tree after the rebase

Phase 4a's stack was cut from main at 419aace and built in parallel
with 4b; 4b and 4c merged first, so the three branches were rebased
onto their main before merging. The rebase kept both sides of every
shared file -- the entry file's three phase-4 blocks in sub-phase
order, the smoke suite's three layer pins, the merged built-phases
sentences and counts in CLAUDE.md, the READMEs and architecture.md --
and regenerated the surface manifest on the merged tree (665 + 65 =
730 rows, none of 4b's or 4c's changed). The status note keeps the
build's own numbers and says what they became.
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 15-phase-4b-recovery-primitives branch September 16, 2026 20:11
@Wahbeh-Mohammad Wahbeh-Mohammad mentioned this pull request Sep 25, 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