Hand-written. ../harvested/observability.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.
-
Fiber storage is the diagnostic-context carrier across the whole supported range, its inheritance is copy-on-write, and it is not where
CTX's execution context lives. Supersedesobservability/e0f1e864("Verified behaviour:Fiber[:key]is inherited by a child fiber, by a newly createdThread, and by anEnumerator's internal fiber, whileThread.current[:key]— despite its name — is fiber-local and visible in none of them"), which carries no version qualifier at all and omits the half a phase most needs. Re-verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6, one case per claim: on all three,Fiber[:k]set in the main fiber is visible insideFiber.new { }, insideThread.new { }and inside anEnumerator's internal fiber, whileThread.current[:k]isnilin all three of those places. Two properties the entry does not record and a design leans on: inheritance is copy-on-write — a write inside a child fiber does not escape to the parent, so a per-request context installed on a worker cannot leak back into the pool's own storage — andFiber.new(storage: nil)opts out entirely, yielding an empty map. That last one is an opt-out at fiber creation and must not be mistaken forASYNC-11's clear:ASYNC-11is about reinstating an empty captured context into a target that already has one ("reinstating an empty context clears the target thread's context rather than raising an error"), which is a pooled worker's restore path, not a fresh fiber's. The write side of that path isFiber#storage=, which is a separate and less comfortable fact — see this file's## Referenceentry on that setter.Fiber#storagereads the whole map on all three. The version widening matters for the same reason it mattered for../notes/pagination.md'sEnumeratorentry and design §3.5'sURI::DEFAULT_PARSERpin:Fiber[]was introduced in 3.2, which isrequired_ruby_version's floor, so an unqualified claim about it is exactly the shape that passes where you look and fails where you do not. It does not; the behaviour is uniform. The boundary this note also draws, because two near neighbours have opposite designs. Fiber storage carries the diagnostic context ofOBS-10,OBS-23,OBS-24andASYNC-8–ASYNC-12— phases 5 and 8. It is not where the execution context ofCTX-1–CTX-20lives:CTX-7andCTX-11require a process-wide store that is thread-safe across distinct call keys and bounded, withCTX-19forbidding weak references and naming the cap as the only sanctioned leak backstop, sodocs/sdk-design-ruby/05-pipeline-architecture.md§5.4 makes it a plainHashbehind aThread::Mutexkeyed by call key. Putting the context store in fiber storage would defeat the cap and the reachability requirement at once, and it is a natural mistake becauseCLAUDE.md's constraints list files the fiber-storage fact under the heading of diagnostic context while the roadmap's phase-4 material is the only place a context store is discussed. Recorded perdocs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md.observability/9f7aa009is unaffected and is adopted verbatim. review ·docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md· high · sha:manual-phase4-fiber-storage-range -
Fiber storage's copy-on-write protects the slot, not a mutable object held in it, and the two write APIs disagree about key types. Narrows
observability/e0f1e864a second time, and with it this file's first## Supersededentry, which added to that harvested rule the property that "inheritance is copy-on-write — a write inside a child fiber does not escape to the parent". That is true of a rebinding and false of a mutation, and the difference decides a design. Verified 2026-09-09 onruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [x86_64-linux], the only interpreter installed on this machine, so this entry is single-interpreter and must be re-run on 3.2.11 and 4.0.6 before anything rests on it — unlike the entry above it, which was run across all three. WithFiber[:mutable] = []set in the parent, a<<insideThread.new { }, insideFiber.new { }and inside anEnumerator's internal fiber all landed in the parent's Array — contents[:thread, :fiber, :enumerator], andobject_ididentical across the thread boundary — whileFiber[:immutable] = :parentrebound to:childin the same three children left the parent's slot at:parent. So a span stack, a counter, or any mutable collection in aFiber[]slot is one shared object across every thread and child fiber descended from the fiber that created it, which isXCUT-11's "any shared mutable state MUST be synchronized" with no synchronisation and is invisible in a single-threaded test; only an immutable value in the slot gets the isolation the copy-on-write phrase suggests. The second half is about the write side the entry above points at this file'sFiber#storage=reference entry for:Fiber.current.storage = {"trace.id" => "x"}raisesTypeError: wrong argument type String (expected Symbol), whileFiber["trace.id"] = "x"succeeds and stores under:"trace.id"— the per-key setter coerces aStringkey to aSymbol, the whole-map setter refuses one, andFiber.current.storagehands every key back as aSymbolwhichever setter wrote it. The consequence forOBS-10,OBS-23,OBS-24andASYNC-8–ASYNC-12: the diagnostic-context key space isSymbol-shaped at the carrier's own API; a published key constant declared as a frozenStringis not directly usable withFiber#storage=, which is the whole-map callASYNC-9's pooled-worker restore may have no way to avoid; and a fold converting a storage key to anOBS-39field key must useSymbol#name, which returns the same frozenStringon every call, neverSymbol#to_s, which allocates a fresh unfrozen one per call (measured 1 against 1001 per 1000 calls).:"trace.id".nameis notequal?to a"trace.id"frozen literal, so the two spellings are two objects and one constant has to be the single source. Recorded perdocs/work/mvp/phase5/phase5c/2026-09-09-phase5c-tracing-and-metrics-design.md(verified fact 2, the slot-versus-object split) anddocs/work/mvp/phase5/phase5b/2026-09-09-phase5b-logging-and-redaction-design.md(verified facts 1 and 10, the key-type split); filed once by the pass that reconciled those two designs, because each declined to write it rather than clobber the other's edit. review ·docs/work/mvp/phase5/phase5c/2026-09-09-phase5c-tracing-and-metrics-design.md· high · sha:manual-phase5-fiber-slot-and-key-type -
OBS-29's emission wiring is a documented follow-up and not a runtime obligation, and the clause that says so lives in appendix C's row and in no chapter — so the harvested rule, and every document derived from chapter 15, states the requirement short. Correctsobservability/2da9e2f3, which ends at "with one tracer instance corresponding 1:1 to a single logical operation" and is exact about its source:docs/product-spec/15-instrumentation-and-observability.md:54ends there too. Appendix C'sOBS-29row does not. It continues "(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." Re-read verbatim 2026-09-13 fromdocs/product-spec/appendix-c-consolidated-normative-requirement-index.md. The divergence runs both ways in the same pair — the chapter carries a*Conformance:*clause ("drive a succeeding and a failing (retry-exhausted) operation through a conformant emitter and assert the ordering and the exhausted→failed pairing") that appendix C drops — so the two rows together are the only complete statement ofOBS-29, and neither says so. That is the same shape as appendix C'sSSE-19row dropping the port sanctiondocs/product-spec/13-server-sent-events-and-streaming.md:33grants, and two instances make it a pattern rather than an anecdote; both are recorded indocs/deviations.md§ Deviations found outside a phase for a human to amend, sincedocs/product-spec/is frozen. What the clause decides, and what it cost that it was missed.OBS-29conditions its own enforcement: the SDK owes a documented ordering contract, and wiring an emitter is explicitly a follow-up. Phase 5c ships the eleven-method vocabulary, the shared no-op and an ordering test, and phase 6a emits the per-attempt group throughhttp_tracer_factory:called withcursor— so the MUST is met, and the operation-lifecycle triple and transport-milestone group having no wired emitter in v1 is conforming rather than a gap. Five documents across four phases (5b, 5c, 6a, 8a and the roadmap's phase-10 inbound list) reasoned fromdocs/sdk-design-ruby/08-instrumentation-and-configuration.md§8.1's restatement of the chapter, which cannot carry a clause the chapter does not have, and carried an "open surface decision" — a new step atStages::PRE_REDIRECT, and/or a widening ofRequestOptions— that the requirement had already closed. Nothing was built wrongly: every one of those phases declined the wiring, 6a under itsR15and 8a asP8-7. What it cost is that the decision travelled as open through five documents and one register retirement, and that a phase with a repair budget could have spent it widening a public surfaceNFR-4would then lock with no caller — which is theEvent#tagmistake (P5-18) in a second place. The second thing this entry fixes, because the same paragraph is where it hides. "Per-operation tracer factory" names two objects and the two are not interchangeable.CTX-14(MUST) puts a factory on the correlation bundle;OBS-25(MUST) requires "a no-op HTTP-tracer / tracer-factory" and that "Selecting a no-op path MUST NOT allocate per call", so a no-op factory returns the same object every time — the opposite of one instance per operation — and phase 4a'sP4-8boundBundle#tracer_factorytoopentelemetry-api'sTracerProvidershape,#tracer(name = nil, version = nil), keyed by instrumentation-library name and version. So the bundle's factory produces span tracers (OBS-21–OBS-25) and is legitimately shared or cached, whileOBS-29's produces HTTP-tracers (OBS-28's eleven-method vocabulary) and is legitimately per operation. Appendix C, design §8.1, phase 5c's Tasks 3–5 and phase 4a'sR3all read them as one object; phase 5c'sP5-43reconciles the no-op case only and says nothing about a recording one.observability/4044a5c7is unaffected and adopted verbatim — the duck-typed listener it describes is the same vocabulary. This file's two other## Supersededentries (sha:manual-phase4-fiber-storage-rangeandsha:manual-phase5-fiber-slot-and-key-type) are about the diagnostic-context carrier and are untouched by this one; they are named by marker rather than by key because a backticked key must resolve to a harvested entry, and a note's own key is not one. review ·docs/work/mvp/phase10/2026-09-13-phase10-deviation-reconciliation-and-release-readiness-design.md· high · sha:manual-phase10-obs29-follow-up-clause -
The per-key fiber-storage API is not uniform across the supported range:
Fiber[:k] = nildeletes the key on Ruby 3.3 and later and RETAINS it with anilvalue on 3.2, andFiber["k"]interns aStringkey on 3.4 and later and raisesTypeErroron 3.2 and 3.3. Narrowsobservability/e0f1e864a third time, on the write side, and is the re-run this file's second## Supersededentry (sha:manual-phase5-fiber-slot-and-key-type, single-interpreter on 3.4.10) asked for before anything rested on it. Measured 2026-09-17 by phase 5c's implementation on 3.2.11, 3.3.12, 3.4.10 and 4.0.6, one script per interpreter and then as a standing test (gems/dexpace-core/test/dexpace/instrumentation/tracing_matrix_facts_test.rb, which pins both boundaries againstRUBY_VERSIONso a patch release that moves either fails the matrix). (1) AfterFiber[:k] = "v"; Fiber[:k] = nil,Fiber.current.storage.key?(:k)isfalseon 3.3.12, 3.4.10 and 4.0.6 andtrueon 3.2.11, whereFiber.current.storagereads{k: nil};Fiber[:k]readsnilon every row, a child fiber and a new thread inherit the nil-valued key on 3.2, and the floor offers no other removal -- its wholeFiberAPI is[],[]=,storageandstorage=, and the returned storage is a copy whose mutation changes nothing -- so the one way to remove a key on 3.2 is the warned whole-map setter this file's## Referenceentry records. Phase 5c's design read "Fiber[:k] = nildeletes the key" as a fact of the range (its verified fact 4, the charter's fact 1); it is a fact of 3.3 and later. (2)Fiber["dexpace.probe"] = 1and the readFiber["dexpace.probe"]both raiseTypeError: wrong argument type String (expected Symbol)on 3.2.11 and 3.3.12 and intern to:"dexpace.probe"on 3.4.10 and 4.0.6, so the entry above's "the per-key setter coerces aStringkey to aSymbol" holds from 3.4 only;Fiber#storage=refuses aStringkey on every row, as it said. What follows. ForOBS-23, phase 5c's per-key restore stays branchless -- "restore each key to its prior value (or remove it if previously unset)" is one assignment on 3.3+ and, on the floor, a present-and-nilkey thatFiber[]reads identically and thatOBS-10's "Keys with null values MUST be skipped" folds identically, which is exactly the state itsP5-49already argues about from the other direction; the as-built rowP5-72records the floor half, and no core file calls the warned setter. ForOBS-24's union restore (5b'sP5-23) andASYNC-9/ASYNC-11's pooled-worker restore (8b), the same holds: a per-key restore returns a 3.2 worker to a map of nil-valued keys rather than to an empty map, indistinguishable at every reader that skips nulls and visible only throughFiber.current.storageitself; both plans were written on the 3.4.10 fact and are audit work for phase 10 (the roadmap's inbound list, the thirty-ninth bullet). For the key TYPE, this settlesR11harder than the reconciliation did: aSymbolis the one spelling every carrier API accepts on every row, and a frozen-Stringkey constant would have raised on the floor at the firstFiber[]=, not merely at the whole-map setter. Read-side inheritance, copy-on-write per slot, the warned setter's per-call warning and thestorage = nildivergence all re-measured as the entries above and below record. review ·docs/work/mvp/phase5/phase5c/2026-09-09-phase5c-tracing-and-metrics-checklist.md· high · sha:manual-phase5c-fiber-nil-and-string-key-floor
- A pooled worker inherits the pool creator's fiber storage and sees nothing set afterwards, so a
merge-shaped context install leaks assembly-time context into a caller's task. Beside
observability/e0f1e864, and specifically beside this file's second## Supersededentry over it (sha:manual-phase5-fiber-slot-and-key-type), which records that copy-on-write protects the slot and not the object in it; this is the other half of the same write-side story and it decides an implementation. Measured on Ruby 3.4.10:::Thread.newinheritsFiber[]at the moment the thread is created, and a worker created before a key was set readsnilfor it — so a pool's workers carry whatever the fiber that calledPool.buildhappened to hold, and nothing the caller does later reaches them. Phase 5b'sDiagnostics.withmerges on install (snapshot.each { |k, v| Fiber[k] = v }), which is exact for its own consumer and forOBS-24; on a pooled worker the merge leaves the inherited keys visible underneath the caller's snapshot, so a pool built underFiber[:tenant] = "assembly"runs a caller's task withtenant: "assembly"on its log lines. That isASYNC-10's "a stale snapshot from when it was assembled", and it is invisible in any test that builds the carrier in the same context it submits from.dexpace-async-threadfixes it for itself with one line at worker start —::Fiber.current.storage&.each_key { |k| ::Fiber[k] = nil }, safe becauseFiber.current.storagereturns a freshHash— after which merge and replace agree and the union restore returns the worker to empty. The rule generalises to any long-lived carrier this repository creates with::Thread.new: a background exporter, a second executor adapter, or a caller's own worker wrapped inDiagnostics.with. NeitherDiagnostics.withnor design §8.1 needs changing; what needed stating is that the carrier must start empty. As built, 2026-09-21 (phase 8b, measured on 3.2.11, 3.3.12, 3.4.10 and 4.0.6). The clear runs at TWO boundaries, not one: the thread-start line above fixes the construction floor, and the same line in the worker's per-taskensurefixes the reuse floor, becauseDiagnostics.withrestores only(prior.keys | snapshot.keys)and a key the WORK itself writes — an#on_settlehandler, an interceptor, a sink — is in neither set and survives onto the next caller's task (measured:{tenant: "A-LEAK", "trace.id": "CALLER-B"}on the next task with the ensure clear gone; 8b'sP8-20). "Returns the worker to empty" is true at every reader that skips nulls and not at the raw map: on the 3.2 floorFiber[:k] = nilretains the key with a nil value (the entry above,P5-72), so a cleared worker'sFiber.current.storagereads{tenant: nil, …}there and{}on 3.3+, whileFiber[],OBS-10's fold andDiagnostics.capture(which compacts,P5-97) read the same on every row — which is why a test of a worker's context reads through.capture, and why a proof that the ensure clear is present must write a key the worker has NEVER held (a build-time key sits in the floor's prior map as a retained nil, and the union restore resets it, hiding the missing clear on 3.2 alone; 8b'sP8-74). And.capturecarries core's owndexpace.-prefixed slots — 5c's current-span carrier — with the diagnostic keys, so the span active on the caller is current on the worker, which isASYNC-8's purpose; a test comparing what the worker sees drops the reserved prefix, because under the one-processrake test:gemsanother suite can leave a no-op span on the main fiber. Amended after review round 2, 2026-09-21. The rule's first casualty was the gem's own SECOND carrier: the timer thread behindPool#delay, spawned lazily by the first positive delay from that caller's fiber, inherited that caller's storage and was given neither clear and no per-delay snapshot, so every later delay's#on_settleand#thencallback ran under the first caller's context and a key one handler wrote was visible to every later one (measured on 3.2.11 and 4.0.6;ASYNC-8names callbacks beside work). A carrier spawned lazily from a caller's fiber is the worse case: what it inherits is an arbitrary request's context, not the assembler's, and the leak is invisible in any test whose first delay is the one it reads. 8b'sP8-78gives the timer the worker's shape — a per-entryDiagnostics.captureat the scheduling call,Diagnostics.witharound every callback, the two clears on the thread the gem owns, and restore-never-clear for a callback run on some other thread's behalf. The rule, restated: EVERY::Thread.newa library keeps, whichever fiber spawned it and however lazily, starts empty, and every callback it runs on a caller's behalf runs under that caller's captured context. Amended after review round 3, 2026-09-21. The corollary for the carrier's OWN emissions: a net that reports a failing task or callback must sit INSIDE the install, not around it. Both of 8b's nets sat aroundDiagnostics.with, so the gem's defect diagnostic — the one log event it emits on a caller's behalf after the hop — was folded after the restore: notrace.idon the worker and the timer thread, and the CLOSER's id on the closing thread, where the restore had put the closer's context back first (measured on 3.2.11 and 4.0.6;OBS-10's fold reads the emitting fiber at emit time, so an emission's correlation is whatever is installed when#emitruns, never when the failure happened). 8b'sP8-22extension moves both nets inside thewith; the test that pins it posts under one id and closes under another, because a diagnostic tagged with the closer's id is a wrong answer, not a missing one. review ·docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-design.md· high · sha:manual-phase8b-pooled-worker-context-floor Fiber#storage=is the only whole-map write sideASYNC-9/ASYNC-11can use, it warns on every call on every supported Ruby, and it does not behave the same on the floor — so prefer per-key writes. Besideobservability/e0f1e864and this file's two## Supersededentries over it; it is the write-side fact the first of those points here for. Design §8.1 fixes the adapter's shape as "saves the worker's prior storage, installs the captured snapshot for the work's duration and restores it in anensure(ASYNC-9)", with an absent context "capturing as empty and reinstating as a clear rather than a raise (ASYNC-11)".Fiber[]=writes one key; saving and restoring a whole map needsFiber#storage=, andFiber.new(storage:)cannot serve, because a pooled worker's fiber already exists when the task arrives. Two facts about that setter, verified 2026-09-08 on 3.2.11, 3.4.10 and 4.0.6 viamise exec ruby@<v>. (1) It warns on every call, on all three, and at the default warning level.Fiber#storage= is experimental and may be removed in the future!is emitted per call, not once per process (two calls, two warnings, verified), and it appears with plainrubyas well asruby -w— it is not gated behind verbose mode. This repository's gate set runs the real suite underruby -wwith warnings failing the build (docs/sdk-design-ruby/09-toolchain-and-quality-gates.md§9.2), so a per-task save/restore emits one warning per task and fails that gate. The warning's category is:experimental, soWarning[:experimental] = falsesilences it — but that is a process-global flag, and a library setting one on its host is the same imposition the port already refuses forRegexp.timeout(CLAUDE.md, design §4/§6.3), so it is not a fix an adapter may reach for unasked. The remaining routes are a scopedWarning.warnfilter around the call or anNFR-7waiver carrying its reason; which one is right is the deciding phase's call, and neither is free. (2)Fiber.current.storage = nilis not uniform across the range. On 3.2.11 it leavesFiber.current.storageas{}; on 3.4.10 and 4.0.6 it leaves it asnil.= {}yields{}on all three. So the obvious spelling ofASYNC-11's "reinstating an empty context clears the target" reads back differently on the floor than on the rest of the matrix, which is exactly the shape of bug an unqualified version claim hides. What follows for a phase that acts on this — 5 forOBS-10,OBS-23andOBS-24, 8 forASYNC-8–ASYNC-12: write per key (Fiber[k] = v), which warns nowhere and coerces aStringkey as this file's second## Supersededentry records, and reach for the whole-map setter only with one of the two routes above written down beside it. What is not claimed: that fiber storage is the wrong carrier. Read-side inheritance is uniform and copy-on-write across the whole range, re-verified in the same session and recorded in the entries above. Only the write side is in question.CTX's execution-context store touches fiber storage nowhere and is untouched by this. review ·docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md· high · sha:manual-phase4-fiber-storage-setter