Hand-written. ../harvested/error-handling.md is what the documents say; this file is what the
implementation found, and it wins. Each entry names the harvested entry it answers by that entry's
stable key.
- The SDK's error root is a
moduleincluded by every error class, not a base exception class, and there is noAssertfacade. Resolveserror-handling/51261878anderror-handling/75571c73. Both rules were routed here by phase 0's design, which shipped no production code for them to bite on. The first rule's purpose — one root a caller can rescue broadly, with domain trees hanging off it — is adopted; its mechanism, a base class, is unsatisfiable here, and the forcing requirement isXCUT-4: "transport errors ... belong to the runtime's I/O-error family so existing I/O catch sites keep matching", which in Ruby meansDexpace::TransportError < ::IOError. Ruby has single inheritance, so a class root would makeXCUT-4and the one-root rule mutually exclusive, and the choice cannot be deferred past the phase that creates the root. What the SDK does instead:Dexpace::Erroris a module,rescue Dexpace::Errorcatches every including class becauserescuematches withModule#===, and each concrete error picks the Ruby superclass its requirement names —Dexpace::InvalidArgumentError < ::ArgumentErrorin phase 1,Dexpace::TransportError < ::IOErrorin phase 8 — whiledocs/sdk-design-ruby/05-pipeline-architecture.md's#suppressedarray and#full_messageoverride live on the module and arrive with the recovery chain in phase 4 (the suppressed-trail deferral, phase 4b Task 1). The hierarchy stays two levels deep aserror-handling/61ab4fb6asks. The second rule fails on its own premise a second way:Assert::InvariantViolationpresupposes theAssertfacade that../notes/assertions.mdalready replaced with the domain model's own validation helper, so a programmer error here raises through that helper's one class and one message form (SEAM-29,HTTP-4) rather than through a facade this codebase does not have, anderror-handling/ffdf6f4f's "ArgumentErrorsignals caller mistakes; raise it from validation helpers, do not rescue it in business logic" is exactly the discipline that replaces it. One consequence worth stating because it is a trap:Dexpace::ArgumentErroris never defined, in this or any later phase — the name would shadow::ArgumentErrorfor every file insidemodule Dexpace, so a barerescue ArgumentErrorwritten in core would silently stop catching Ruby's own. The styleguide-amendment alternative (rewording rule 8's "one project-level base exception class" as "one project-level root, a class or a module a caller can rescue") was considered and not taken; this is recorded as an SDK deviation instead (phase 1, P1-2 and P1-3). review ·docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-design.md· high · sha:manual-phase1-error-root
- The suppressed trail must be rendered through
#detailed_message, not#full_message: a#full_messageoverride is invisible to Ruby's own uncaught-exception printer. Supersedeserror-handling/34f54b5e("Core's error rootDexpace::Errorcarries a#suppressedarray, frozen once populated, with#full_messageoverridden to render the suppression trail"), whose rule — one core-owned trail on the error root, rendered wherever the error is displayed — is right, and whose mechanism renders it in the one place nobody looks. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The fact: a module definingfull_message(**kw)and included into aStandardErrorsubclass renders the trail correctly when#full_messageis called explicitly, and contributes nothing at all to the default uncaught-exception report —raiseon such an error prints the plainfile:line:in '<main>': boom (MyErr)on all three interpreters, with no trail. The default printer is implemented in C and does not dispatch to a Ruby-levelfull_message. It does dispatch to#detailed_message(added in 3.2, so present on this port's floor): the same module definingdetailed_message(**kw)instead puts the trail into the uncaught-exception report on all three. And Ruby's ownException#full_messagecalls#detailed_messageinternally, verified on all three, so overridingdetailed_messagealone satisfies both paths while overridingfull_messagealone satisfies neither of the ones that matter — an explicite.full_messagecall in a logger being the only route the latter reaches. What the SDK does instead, perdocs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md:Dexpace::Error— a module, per this file's first entry — carries#suppressedand overrides#detailed_message, and phase 4's4bdecides whether#full_messageis also overridden (it need not be). This is the content of the suppressed-trail deferral (phase 4b Task 1) and the carrier of theHooks.notifydropped-failures one (phase 4b Task 2), so it changes what phase 4 builds rather than being noted after the fact. This entry also corrects one clause of the phase-1 note in this file's## Conflictssection, which says the#suppressedarray "and#full_messageoverride live on the module and arrive with the recovery chain in phase 4 (the suppressed-trail deferral, phase 4b Task 1)": everything in that sentence stands except the method name. It is corrected here rather than rewritten there because that note's key is cited bydocs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md, and a note's key is digested from its text, so editing it in place would retire a live citation. A--origin notereader who meets the## Conflictsentry first must read this one as the later word on the mechanism.error-handling/5a8298f6(Dexpace.attach_suppressedwith the self-suppression guard) anderror-handling/87ce2196(#causeis never used for the trail) are unaffected and are adopted verbatim. review ·docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md· high · sha:manual-phase4-detailed-message - The
#causecycleXCUT-9guards against is not reachable by the route the design names — Ruby actively rejects it — and testing that route would suggest the guard is unnecessary. Supersedeserror-handling/11c6f36c("Ruby does not prevent cause-chain cycles because#causeis settable throughException#exception, and application re-raise chains can close on themselves, turning an infinite cause walk inside a rescue handler into an unkillable hang"), whose conclusion — the walk must be cycle-safe — stands, and whose two stated mechanisms are both false. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The facts, all four uniform across the range.raise y, cause: xwhenx.causeis alreadyyraisesArgumentError: circular causes— the VM refuses to close a two-node chain.raise s, cause: sis accepted and leavess.causenil; so does re-raising the same object while it is$!. AndException#exception("msg")returns a new object whose#causeisnil, so it is not a route to setting a cause at all. The route that does work, and that the design does not name: a caller-defined#causeoverride.class Loopy < StandardError; def cause = self; endself-cycles, and two wrapper objects each returning the other cycle two ways — both verified on all three. That is the shape core cannot control, becauseDexpace.each_causewalks caller-supplied errors: a third-party transport adapter's error class, or an application's own wrapper.XCUT-9therefore stays a live MUST anderror-handling/5a7d53ab'sequal?-tracked visited set stays necessary; only the test that proves it changes, and a phase-4 implementer who builds the cycle the way the superseded entry describes gets anArgumentErrorand could reasonably conclude Ruby prevents cycles and drop the guard.error-handling/c1fa7ee8(theXCUT-9requirement itself) anderror-handling/71ef8cb1(the rule) are unaffected and are adopted verbatim. review ·docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md· high · sha:manual-phase4-cause-cycle-route - The reason
Dexpace.each_causemust track by reference identity is Ruby's ownException#==, notData's structural equality — and the container that gets it right is{}.compare_by_identity, not anArrayand not aSet. Supersedeserror-handling/5a7d53ab("Core provides oneDexpace.each_cause(error)enumerator that tracks visited objects byequal?rather than==, because core'sData-based errors define structural equality that would otherwise truncate a legitimate chain of two distinct errors carrying identical fields"), whose conclusion — track byequal?— is right and load-bearing, and whose stated reason is false about this codebase: core's errors are notDataat all.Dexpace::Erroris a module included by ordinaryStandardErrorsubclasses (this file's## Conflictsentry, phase 1's P1-2), so noData-generated==is anywhere near a cause chain. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The real mechanism:Exception#==is structural by Ruby's own definition — same class, same message, same backtrace — so two distinct un-raised errors of one class with one message are==(trueon all three), and so are two raised at the same source line, while#eql?and#hashareObject's and therefore identity. The truncation the entry warns about is therefore reachable through Ruby's semantics with noDatainvolved. The consequence the entry does not reach, and it decides an implementation: a visitedArraywith#include?uses==and truncates — measured, a two-node chain of==-equal errors yields 1 where the correct answer is 2. How that pair is built is part of the finding, because the obvious construction hides the bug on the supported floor. Chaining two same-message errors by raising — raise the parent, then raise the child from therescueso Ruby links them — makes them==on 3.4.10 and 4.0.6 and not==on 3.2.11, because 3.2's backtrace carries arescue in <method>frame the parent's does not andException#==compares the backtrace; measured, anArray-tracked walk over that pair yields 2, 1, 1 across 3.2.11 / 3.4.10 / 4.0.6, so the truncation is simply absent on the floor. The construction that is uniform is two never-raised instances, whose backtraces are bothniland which are therefore==on all three, chained by a caller-defined#causeoverride: measuredArray1 / identity 2 on all three, and withhash/eql?also overridden,Set1 / identity 2 on all three. A cycle-guard or identity-tracking test built theraiseway is green on 3.2.11 against anArray-tracked implementation, which is the matrix column that exists to catch it. ASetor a plainHashuses#eql?/#hash, which for a bareExceptionare identity and therefore accidentally correct — but a caller-supplied error class that overrideshash/eql?structurally defeats them, measuredSet[a].include?(b) == trueon all three, andDexpace.each_causewalks caller-supplied errors by construction. Only{}.compare_by_identityignores both overrides; measuredfalsefor the same pair on all three. What the SDK does, perdocs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md:Dexpace.each_cause's visited set is aHashbuilt with#compare_by_identity, never anArray, never aSet, and core therefore requires nothing new —setis on phase 0's allowlist but is not reached.error-handling/c1fa7ee8(XCUT-9itself) anderror-handling/71ef8cb1(the rule) are unaffected and adopted verbatim, and so is this file's own preceding entry on which route builds the cycle at all; this entry answers a different question — which container makes the tracking true. review ·docs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md· high · sha:manual-phase4b-identity-container - The suppressed trail cannot live only on
Dexpace::Error, because every primaryRECOV-12,Dexpace.close_quietly's disposal routes (phase 4b Task 2, phase 5b Task 14) andHooks.notify's attached handler failures (phase 4b Task 2) hand it is a caller-supplied exception; it lives on a separateDexpace::Suppressiblemodule thatDexpace::Errorincludes andattach_suppressedextends onto anything else. Supersedeserror-handling/5a8298f6("OneDexpace.attach_suppressed(primary, secondary)helper skips attaching an exception to itself, implementing the self-suppression guard"), whose self-suppression clause stands verbatim and whose silence about the primary's type is what needed answering: with the trail defined onDexpace::Erroralone, the helper raisesNoMethodErrorthe first time a third-party step throws, which isRECOV-12's ordinary case rather than an edge one — a recovery step is caller-supplied and its throwable is whatever it raised, and theHooks.notifydeferral's own carrier is a hook failure that phase 2's tests raise as a bare::IOError. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The facts.Object#extendon an exception instance puts the module in that object's singleton class, and a#detailed_messagedefined there reaches the default uncaught-exception printer exactly as an included one does — the trail prints underraiseon all three — whilesuperstill reaches Ruby's own, so aNoMethodError'sdid_you_meansuggestion survives (measured: kept, all three).extendcosts roughly four times a bare exception allocation (5 ms vs 22-27 ms per 50 000) and only on an error path. It raisesFrozenErroron a frozen exception, and so does the ivar write, on all three. Andrescue Mdoes match a module reached through a singleton class, which is why the trail must not beDexpace::Erroritself: extending a third-partyIOErrorwith the rescue root would makerescue Dexpace::Errorcatch errors the SDK did not raise. What the SDK does:Dexpace::Suppressiblecarries#suppressedand#detailed_message;Dexpace::Errorincludes it, so every SDK error has the trail andrescue Dexpace::Errorkeeps its phase-1 meaning;Dexpace.attach_suppressed(primary, secondary)skips self (RETRY-34),extendsprimarywithSuppressiblewhen it is not already, appends, and rescuesFrozenErrorinto a documented no-op because a helper that raises on a cleanup path isRECOV-12's masked-primary failure; andDexpace.suppressed(error)reads the trail off anything, returning a frozen empty array when there is none.error-handling/34f54b5eis separately superseded above on the method name; the two findings are independent and both apply. review ·docs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md· high · sha:manual-phase4b-suppressible-module - A wrap whose original is
Encoding::UndefinedConversionErrororEncoding::InvalidByteSequenceErrorover a secret must NOT carry it as thecause:: the original's message names a character or byte of the input, and#full_messagerenders the cause chain. Narrowserror-handling/866b8ebe("Thecause:passed on rethrow must be the original exception object, not a string, and raw third-party exceptions must be wrapped in a typed domain class before propagating"), whose wrapping half stands and whose "the original exception object" half is wrong for exactly one class of input — a value that is itself a secret. Verified on 2026-09-18 against 3.2.11 and 4.0.6, identically:"hunter日2".encode("ISO-8859-1")raises with the messageU+65E5 from UTF-8 to ISO-8859-1, where U+65E5 is 日, a character of the password, and"p\xE4".b.encode("UTF-8")with"\xE4" from ASCII-8BIT to UTF-8, a byte of it; a typed error raised with that object ascause:has a clean#message,#detailed_messageand#inspect, buterror.full_message(highlight: false)— what Ruby prints for an uncaught exception, and what any cause walk such asDexpace.each_causereaches — contains the codepoint on both interpreters. Review round 1 of phase 6c found it (R1-3) after round 0's checks had covered the three clean renderings and not the chain. What the SDK does, perdocs/work/mvp/phase6/phase6c/2026-09-09-phase6c-authentication-design.md's as-built row P6-85:Dexpace::Auth::DigestHandler#materializeandDexpace::Auth::BasicHandlerraise their typed failure withcause: nilon both the rescue path and the validity-check path, and carry the information the dropped message held that was NOT a character of the secret — the value's own encoding name — asUnencodableCredentialError#source_encodingand in both messages ("cannot be encoded as ISO-8859-1 from UTF-8"), so the diagnostic loses only the character.error-handling/6d4dbc71's "every wrap-and-rethrow must passcause:" is honoured literally — the keyword is passed, and its value is nil, which also keeps a caller's in-flight$!from being assigned (../notes/pipeline.md'scause: nildiscipline) — andauthentication/10d2165f(AUTH-8's "any string/diagnostic representation") is the rule this rests on. What it licenses for later phases: any boundary that transcodes or parses caller-supplied secret material — a serde decoder over a credential-bearing document, a transport adapter re-validating a stampedAuthorizationvalue — drops the encoding error from the chain and names the field and the encodings instead. What it does not license: dropping the cause for a non-secret input; phase 1'sURL.parse!keepsURI::InvalidURIErroras its cause because a URL is not a secret, and the rule stands there as written. review ·docs/work/mvp/phase6/phase6c/2026-09-09-phase6c-authentication-checklist.md· high · sha:manual-phase6c-secret-cause