As built by phase 4a, in dexpace-core, written against source on 2026-09-16. This page says
what one in-flight call carries from before its request exists to after its response has arrived:
three immutable context flavours forming a one-way promotion chain, one call-unique key they share,
one bounded process-wide store they register in, and the instrumentation bundle every one of them
holds. What each is required to do is docs/product-spec/07-execution-context-model.md; how the
design maps it to Ruby is docs/sdk-design-ruby/05-pipeline-architecture.md §5.4 and
docs/sdk-design-ruby/08-instrumentation-and-configuration.md §8.1; the per-requirement proof is
docs/work/mvp/phase4/phase4a/2026-09-08-phase4a-execution-context-checklist.md. Signatures live in
gems/dexpace-core/sig/dexpace/, and this page does not restate them. Every example below was run
against the built code on 4.0.6 and 3.2.11 and printed the same on both.
Nothing in v1 drives this chain for you. No pipeline step, recovery step or transport
constructs or promotes a context (docs/first-release.md § What v1 ships without records why that
is conforming): a generated client builds the head, promotes it as its request and its response
come into being, and closes the last link. This page is written for that author.
Every context carries a bundle — the correlation metadata that lets logs, metrics and spans
across the three stages correlate into one trace (CTX-14). It is a frozen Data of eight members
— trace_id, span_id, trace_flags, trace_state, flavour, remote, span, tracer_factory
— and one derived predicate, #valid?, which is a function of the two identifiers rather than a
ninth member: OBS-26 makes "an all-zero trace/span id MUST be treated as invalid" a MUST, so a
bundle cannot be told it is valid while carrying a sentinel. #remote? reads remote as a
predicate.
The untraced bundle is Bundle::NONE, one shared frozen object carrying OBS-26's reserved
sentinels — 32 hex zeros, 16 hex zeros, flags "00", an empty state — and the two no-op singletons:
bundle = Dexpace::Instrumentation::Bundle::NONE
bundle.trace_id # => "00000000000000000000000000000000"
bundle.span_id # => "0000000000000000"
bundle.trace_flags # => "00"
bundle.trace_state # => []
bundle.flavour.name # => :none
bundle.valid? # => false
bundle.remote? # => false
bundle.span.equal?(Dexpace::Instrumentation::NO_SPAN) # => true
bundle.tracer_factory.tracer.equal?(Dexpace::Instrumentation::NO_TRACER) # => true
bundle.frozen? # => trueNONE is available as the default and is never defaulted for you. bundle: is a required
keyword on every context builder, and trace_id:, span_id: and flavour: are required on
Bundle.build, so "I forgot to pass a trace id" is a missing keyword and not a silently untraced
call — every untraced call's identifiers are identical (CTX-15), which is exactly why an accident
would be invisible downstream. A caller who wants the untraced value names NONE.
A real bundle is built through Bundle.build and validated member by member: the trace id must be
one its flavour renders, the span id 16 lowercase hex characters or the sentinel, the flags two
lowercase hex characters, the state a list of [String, String] pairs (a list, not a Hash —
W3C tracestate is ordered). Lowercase is enforced rather than folded: 0A is rejected where the
wire form is 0a. The state list is deep-copied and deep-frozen at construction, so the caller's
array is untouched and the bundle's cannot be reached through it.
traced = Dexpace::Instrumentation::Bundle.build(
trace_id: "4bf92f3577b34da6a3ce929d0e0e4736", span_id: "00f067aa0ba902b7",
flavour: Dexpace::Instrumentation::TraceIdFlavour::W3C, trace_flags: "01",
trace_state: [%w[vendor value]], remote: true,
)
traced.valid? # => true
traced.remote? # => true
traced.trace_state.frozen? # => true
Dexpace::Instrumentation::Bundle.build(
trace_id: "4BF92F3577B34DA6A3CE929D0E0E4736", span_id: "00f067aa0ba902b7",
flavour: Dexpace::Instrumentation::TraceIdFlavour::W3C,
) # raises Dexpace::InvalidArgumentError,
# "trace_id does not match flavour :w3c"Bundle follows the construction pattern every core model does (new is private, .build
validates, #with routes through .build on every supported Ruby), so
Bundle::NONE.with(trace_id: real, span_id: real, flavour: W3C) is validated exactly as a fresh
build is. That is how phase 5 will populate it; the contract phase 5 inherits — may add methods,
may not add, rename or remove a member, store validity, change the sentinels, replace the flavour
type with a Symbol or hand Bundle a second NONE — is the design's R3.
OBS-27's trace-id encodings, as a closed set of frozen values with an .of factory:
TraceIdFlavour::NONE, ::W3C (32 lowercase hex) and ::DATADOG (a 64-bit unsigned integer as a
decimal string), in ::ALL. Each carries the pattern a renderable trace id matches, the flavour's
own reserved sentinel, and for DATADOG the numeric bound a digit count cannot express. Two
predicates: #valid_trace_id? (renderable and not the sentinel) and #renders? (renderable or the
sentinel — what a bundle may carry). They disagree at exactly one input, the sentinel.
Dexpace::Instrumentation::TraceIdFlavour.of(:datadog).max_value # => 18446744073709551615
Dexpace::Instrumentation::TraceIdFlavour::W3C.valid_trace_id?("0" * 32) # => false
Dexpace::Instrumentation::TraceIdFlavour::W3C.renders?("0" * 32) # => trueThe flavour governs the trace id only. OBS-26 states the span-id rule unqualified and
OBS-27's flavours scope only the trace id, so a Datadog bundle carries a decimal trace id and a
16-hex span id — odd to read, and what the two requirements say together. The span-id sentinel is
the one constant Bundle::INVALID_SPAN_ID; the trace-id sentinel is a property of the flavour,
because DATADOG's own zero draw is "0" while OBS-26's reserved value is 32 hex zeros.
Dexpace::Instrumentation::NO_SPAN, NO_TRACER and NO_TRACER_FACTORY are three frozen objects
whose classes are private. Phase 4 fixes the slots and the identity of the objects filling them;
phase 5c gives their classes the span and tracer protocols, and the objects keep their identity, so
OBS-25's "selecting a no-op path MUST NOT allocate per call" is assertable with equal? and stays
true across that widening. Today NO_SPAN and NO_TRACER answer nothing beyond Object, and the
factory answers exactly one method, #tracer, whose parameter list is opentelemetry-api 1.11.0's
own TracerProvider#tracer so that an application already running OpenTelemetry can hand its
tracer_provider straight to Bundle.build(tracer_factory:):
factory = Dexpace::Instrumentation::NO_TRACER_FACTORY
factory.tracer("my-lib", "1.0").equal?(Dexpace::Instrumentation::NO_TRACER) # => true
factory.tracer(name: "my-lib", version: "1.0", attributes: {}).equal?(Dexpace::Instrumentation::NO_TRACER) # => true
factory.method(:tracer).parameters
# => [[:opt, :deprecated_name], [:opt, :deprecated_version], [:key, :name], [:key, :version], [:key, :attributes]]It is safe to call from any thread because it holds no state (CTX-20).
Three flat Data classes, each including Dexpace::Context, each frozen on construction. The
head, Dexpace::DispatchContext, exists before any request does and carries bundle,
call_key and store. #promote_to_request(request:, operation_name: nil) returns a
RequestContext that carries the same three members forward — the same objects, not copies — and
adds the request; the operation name enters here as an argument, because the head has no such
field. #promote_to_exchange(response:) on a RequestContext returns an ExchangeContext
carrying all five forward and adding the response. ExchangeContext has no promotion method at
all: the chain is one-way and the exchange stage is terminal (CTX-1), enforced by the absence of a
method rather than by a guard.
request = Dexpace::Request.builder.tap { |b| b.url = "https://api.example.test/pets" }.build
response = Dexpace::Response.builder.tap do |b|
b.request = request
b.protocol = Dexpace::Protocol::HTTP_1_1
b.status = 200
end.build
store = Dexpace::ContextStore.new(cap: 8) # a private store for the example; see below
dispatch = Dexpace::DispatchContext.build(bundle: bundle, store: store)
dispatch.call_key # => "00000000000000000000000000000000:0000000000000000:1"
store.size # => 0 -- construction registers nothing (CTX-17)
dispatch.close # => false -- a never-promoted head closes as a no-op
request_ctx = dispatch.promote_to_request(request: request, operation_name: "ListPets")
request_ctx.class # => Dexpace::RequestContext
request_ctx.call_key.equal?(dispatch.call_key) # => true
request_ctx.bundle.equal?(dispatch.bundle) # => true
request_ctx.operation_name # => "ListPets"
store.size # => 1 -- the first promotion is the first entry
store[dispatch.call_key].equal?(request_ctx) # => true
exchange_ctx = request_ctx.promote_to_exchange(response: response)
exchange_ctx.class # => Dexpace::ExchangeContext
store.size # => 1 -- the same slot, overwritten (CTX-3)
store[dispatch.call_key].equal?(exchange_ctx) # => true
exchange_ctx.respond_to?(:promote_to_request) # => falseConstruction registers nothing; promotion registers the successor. .build on any flavour —
DispatchContext.build(bundle:, call_key: nil, store:), RequestContext.build(bundle:, request:, operation_name: nil, call_key: nil, store:), ExchangeContext.build(bundle:, request:, response:, operation_name: nil, call_key: nil, store:) — touches no store, so an off-chain context is a value
and nothing else. The two #promote_* methods are the only callers of ContextStore#set in core.
#with is not a promotion and cannot be used as one: it reaches only the members its own flavour
declares (dispatch.with(request: r) raises ArgumentError: unknown keyword), so the promotion
methods are the only way to add an artefact, which is what keeps registration on the promotion
path.
RequestContext#operation_name and ExchangeContext#operation_name carry a schema-defined
operation id — "ListPets" — or nil; an empty string is refused, and so is anything that is not a
String (a Symbol spelling of the same id included), with Dexpace::InvalidArgumentError
operation_name must be a String. It is carried forward unchanged
and is advisory only: it influences neither the request, nor any dispatch decision, nor the
store key (CTX-16). It is the chain half of SEAM-28's postponed operation identifier; the
tracing seam that will read it is phase 5c's.
#close on any link is store.release(self): the slot is cleared only when its current
occupant is this very object — reference identity, never value equality — and the return value
says whether it was. So closing the exchange context evicts it; closing the request context it was
promoted from is a no-op, because the slot now holds the successor; closing twice is a no-op; and a
context has no closed latch to carry, because a frozen Data cannot hold one and the store already
answers the question.
request_ctx.close # => false -- promoted intermediate, no-op (CTX-10)
store[dispatch.call_key].equal?(exchange_ctx) # => true
exchange_ctx.close # => true -- the occupant, evicted
store[dispatch.call_key] # => nil
exchange_ctx.close # => false -- already gone, still a no-op (CTX-18)Close the furthest link you reached. There is no block form on the head for that reason: a
block around DispatchContext.build would close the head — a no-op — and leak the exchange context
that actually occupies the slot. The bounded cap below is the backstop for a chain nobody closes,
and it is the only backstop: nothing here is held weakly (CTX-19).
Every context carries a call key, a frozen String unique per call across the whole process
and all three flavours (CTX-4, CTX-6). Absent an explicit one, it is minted as
"traceId:spanId:n" from the bundle's two identifiers and a process-wide counter — the counter is
what keeps it unique when every untraced call shares NONE's identical identifiers. The key
participates in value equality, and that has a consequence worth reading twice:
a = Dexpace::DispatchContext.build(bundle: bundle, store: store)
b = Dexpace::DispatchContext.build(bundle: bundle, store: store)
a == b # => false -- two default-constructed contexts are never equal (CTX-5)
a.call_key == b.call_key # => falseTwo contexts built from the same bundle with otherwise identical fields are not equal, because
their minted keys differ. A caller who needs value equality pins a shared key with call_key: at
construction — the one escape hatch, and the one way to make two contexts that are == and not
equal?, which is exactly the pair the identity rule of #close is about:
c = Dexpace::DispatchContext.build(bundle: bundle, store: store, call_key: "shared")
d = Dexpace::DispatchContext.build(bundle: bundle, store: store, call_key: "shared")
c == d # => true
c.equal?(d) # => false
store.set(c)
store.release(d) # => false -- a value-equal sibling does not evict the occupant (CTX-9)
store["shared"].equal?(c) # => true
store.release(c) # => trueA pinned key must be a non-empty String — a Symbol or an Integer is refused with
Dexpace::InvalidArgumentError call_key must be a String rather than keying a slot no String
lookup finds — and is frozen without aliasing the caller's String; it restores equality only
between contexts built against the same store object, since store is a member too.
One process-wide registry of in-flight chains, keyed by call key, safe to use from any thread
without external locking (CTX-7). ContextStore.default is the one process-wide instance,
built at load with the cap ContextStore::MAX_TRACKED_CONTEXTS (1024, the one number the
specification supplies for a store of this shape); every context builder defaults store: to it.
ContextStore.new(cap:) builds a private one — what a test uses, and where phase 5's
configuration will attach a configured cap without changing a signature.
Two ways in, both taking a context and reading #call_key off it (CTX-8): #set is
install-or-replace and never raises — what promotion uses — and #put installs only if the slot
is empty and otherwise raises Dexpace::ContextConflictError, whose #call_key and message name
the key. Under a race, exactly one #put wins. Two ways out: #[](call_key) answers the
occupant or nil for an unknown or evicted key, never raising (CTX-18), and #release(context)
is the identity-conditional eviction #close delegates to. #size is the only aggregate read;
there is no iteration, deliberately.
store.put(c)
store.put(d) # raises Dexpace::ContextConflictError
# e.call_key # => "shared"
# e.message # => "a context is already registered under call key \"shared\" (CTX-8)"
store.set(d).equal?(d) # => true -- install-or-replaceThe cap is a hard bound and the leak backstop. After every insert the store drains back to its
cap by evicting the oldest registration first (CTX-11, CTX-12); re-setting a key through
promotion does not refresh its position. CTX-13 grants the store latitude over which entry goes,
and forbids relying on any particular one surviving — including the one just inserted — which core
obeys structurally: nothing in core reads a context back out. A chain nobody closes therefore
costs at most the cap's worth of entries, and never a collection mid-call: the map is a strong
Hash, and a custom cop, Dexpace/NoWeakReferences, refuses ObjectSpace::WeakMap,
ObjectSpace::WeakKeyMap and WeakRef in every gem's lib/ (CTX-19).
small = Dexpace::ContextStore.new(cap: 2)
first = Dexpace::DispatchContext.build(bundle: bundle, store: small).promote_to_request(request: request)
Dexpace::DispatchContext.build(bundle: bundle, store: small).promote_to_request(request: request)
Dexpace::DispatchContext.build(bundle: bundle, store: small).promote_to_request(request: request)
small.size # => 2
small[first.call_key] # => nil -- the oldest registration went
first.close # => false -- and closing it is still a well-defined no-op
Dexpace::ContextStore::MAX_TRACKED_CONTEXTS # => 1024
Dexpace::ContextStore.default.equal?(Dexpace::ContextStore.default) # => trueThe store is process-wide, not fiber-scoped. Fiber[:key] is the SDK's diagnostic-context
carrier (CLAUDE.md, "Constraints that will bite"); the execution context is not stored there and
must not be: a context registered on one thread is found by store[key] from a child fiber, a new
thread and an enumerator's internal fiber alike, because a bounded, reachable, closeable registry is
the opposite design from an inherited per-fiber map.
- Every context is frozen and every member it holds is the object it was given; a
Responsereachable through a registeredExchangeContextstays reachable — body and connection included — until the chain is closed or the cap evicts it. Close what you promote. #inspecton a registered context walks the store.storeis a member, soData's generated#inspectprints one level of every other occupant; at 1024 entries with real request/response graphs,p ctxand a failingassert_equalon whole contexts are dumps of every in-flight call. Compare members, not contexts; a redaction-aware rendering is phase 5's.Dexpace::Contextis a module the three flavours include, not a namespace, soRequestandResponseinsidemodule Dexpacekeep meaning phase 1's types; the flavours are flat underDexpace::and the instrumentation subsystem keeps its own namespace.Dexpace::BoundedMapandDexpace::CallKeyare private. The map is the one bounded-keyed-map implementation later phases share (AUTH-19's nonce counters,XCUT-14's audit), reachable by a bare name from inside amodule Dexpace; …body only; the key format is a reference choice a port may change. Neither is part of the locked API.- What this page does not show, because it is not built: a pipeline that promotes the chain, a tracer or span that does anything, a configured cap. The first two are phases 4c, 5c and 6a's; the third is phase 5a's.