ADR-0063: An opaque caller context rides external events and the durable-timer effects
Copy Markdown View SourceStatus: accepted (2026-08-20) - decides the field ADR-0062 decision 4
names ("caller trace context on external events and on %SendDelayed{}")
and discharges the "future work" caveat in docs/opentelemetry.md's
session-process paragraph; amends 0040 in part (four events' metadata
gain caller_context), the same additive reopening 0046 and 0059 made;
ADR-0054's dedup and cancellation keys are untouched
Context
docs/opentelemetry.md's session-process caveat states the gap this
record closes: :telemetry.execute/3 is synchronous, so the
opentelemetry_statifier bridge's handlers run in the session's own
GenServer process, where the sender's OTel context is never ambient -
by the time a macrostep span opens, the caller's context stayed in the
caller's process. Two wants are impossible until the events carry the
context as data:
- Caller-attached macrostep spans. A host sending an event from inside a traced request wants the resulting macrostep span linked to (or parented under) its own span. The bridge reads the public telemetry events and nothing else (ADR-0062 decision 4), so the context must arrive in event metadata.
- Span links from a durable timer's firing back to the trace that
scheduled it.
statifier_obanstores%SendDelayed{}rows and fires them hours later, in a worker process with no memory of the scheduling trace. The firing site can only link back if the effect row itself carried the context.%Cancel{}is processed against the same store and wants the same attribution for the cancellation act.
ADR-0062 decision 4 already chose the mechanism class: when the bridge needs data the events lack, "the field is added here under ADR-0040's amendment discipline", never by the bridge reaching into internals. This record is that addition. Constraints already on the books bound its shape:
- The library never interprets the value.
statifiertakes no OTel dependency, ever (ADR-0062 decision 1). Whatever the host puts in the slot - an OTel span context, a request id, a correlation map - is opaque here: copied, carried, and handed back, never read. - The stamp must be the core's, not the session's. A session-side
stamp at plan or delivery time fails replay, for the reason ADR-0046
recorded when it rejected that shape for
roundand ADR-0059 cited again forordinal:Statifier.Replayre-drives the pure fold andSession.Effects.plan/2with no session behind them (ADR-0034), so anything a live session added outside the fold would be absent from the replayed stream and the byte-identical claim would break. - The recording already stores events verbatim.
Statifier.Session.Recording'sput_event/3,put_invoked_event/4, andput_timer/4append the%Statifier.Event{}as given (ADR-0029's recorded inputs, ADR-0057's envelope). A field on the event struct therefore reaches the recording by default; stripping it would be new code whose only effect is losing the input replay needs. - Additive is the open direction. ADR-0040's st-ii9v amendment records that adding a metadata key to a published event is non-breaking while removing one after the bridge ships is breaking; ADR-0046 and ADR-0059 both reopened 0040 through exactly that door.
Decision
1. The field is caller_context :: term(), defaulting to nil, and
the library never reads it. Named for what it holds - the caller's
context at send time - not for one consumer: nothing in the name or type
commits the slot to OpenTelemetry, and a host may carry any correlation
value. nil means "no context attached"; the :undefined convention on
Statifier.Event.data does not apply, because this slot never holds
datamodel content and a datamodel null can never be a caller context, so
nil is unambiguous here for the same reason it is on sendid and
origin (the event moduledoc's own argument). The field joins no
@enforce_keys list. The library's whole contract with the value is:
copy it where this record says, expose it where this record says, and
never pattern-match, transform, or branch on it anywhere.
2. Three carriers: %Statifier.Event{}, %SendDelayed{}, and
%Cancel{}. No other struct gains the field.
%Statifier.Event{}gainscaller_context, settable only throughEvent.external/2's opts.internal/3andplatform/3never read it from opts: an event the chart raised has no external caller, and its macrostep's attribution belongs to whatever external input opened that macrostep. No newStatifier.Sessionfunction is needed - a host attaches context by building the event (Event.external(name, caller_context: ctx)) and passing it to the existingsend_event/2/send_invoked_event/3; the binary conveniencesend_event(server, "name")naturally attaches none.%SendDelayed{}and%Cancel{}each gaincaller_context, stamped at their existing construction sites (decision 3). Both, not just the send: the two effects are one durable-timer vocabulary (ADR-0054 decision 1), the same store processes both, and the firing-side host wants the cancellation act attributed exactly as it wants the firing attributed - the same both-or-neither argument that gave%Cancel{}itsordinal(ADR-0059 fact 4, by analogy rather than by dedup mechanics).- No other effect carries it, by the rule ADR-0059 decision 5 already
wrote for
ordinal: the two durable-timer effects outlive the macrostep that emitted them, and no other effect does. An immediate%Send{}is delivered inside the macrostep whose telemetry already carries the context (decision 4); stamping it there would duplicate a value every consumer can already read off the enclosing span. - The slot never reaches the datamodel.
Statifier.Evaluator.SystemVariables.event/1does not surface it, so_eventis unchanged: spec 5.10.1 fixes_event's fields, the value is host plumbing rather than chart-visible data, and a chart that could branch on its caller's trace context would make conformance runs depend on observability wiring.
3. The stamp is the core's: %MachineState{} gains a transient
caller_context :: term() field (default nil) naming the current
macrostep's caller, and the two effect constructors copy it. Every
macrostep-opening core entry point overwrites the field, so it never
holds a stale value: Interpreter.handle_event/2 writes the triggering
event's caller_context at the macrostep's head (beside the existing
begin_macrostep/1 / put_event/2 sequence), and initialize/2 and
cancel/1 write nil - their macrosteps have no sending caller.
Internal-event rounds never touch it: an internal event was raised by
the chart inside the same macrostep, so the macrostep's attribution
stands. <send delay> and <cancel> executed anywhere in that
macrostep - directly, in an internal round, inside a <foreach> - read
the field into the effect at the same sites that read the counters
today. Because the field is pure fold state, ADR-0034's replay re-mints
it byte-identically from the recorded events, which is the whole reason
the session must not be the stamping site.
The chain continues at firing time without a new mechanism:
Statifier.Session.Effects' delivered_event/2 and internal_event/1
- the functions that build the event a
{:schedule, ...}instruction will later deliver - copysend.caller_contextonto the event they construct. An in-process timer firing therefore re-entershandle_event/2carrying the scheduler's context, and the firing macrostep's telemetry attributes to it with no session-side code; a durable host does the same copy at its own firing site (thestatifier_obanhalf). Both functions run insideplan/2, which replay re-drives, so the copy is replay-sound. Autoforwarding needs nothing: the forwarded event is the same%Event{}value, so its slot travels to the invoked child untouched.
4. Four telemetry events gain a caller_context metadata key - the
ADR-0040 amendment. Per 0040's split, the value is identity, not a
number, so it is metadata everywhere and a measurement nowhere:
[:statifier, :session, :macrostep, :start]and[..., :stop]gaincaller_context- the triggering external event's slot,nilfor the:initialize/:cancel/:internaltriggers and for an event sent without one. Both halves carry it for the same reason both carrytriggerandevent_name: a consumer attaching to only one half still attributes. This is the read point for caller-attached macrostep spans.[:statifier, :session, :effect, :send_delayed]and[..., :cancel]gaincaller_contextbeside their existing identity keys. The value also rides inmetadata.effectverbatim by 0040's transitivity rule; the explicit key keeps the bridge's read uniform with the macrostep events rather than making these two the only events it destructures a struct for.
No event is added or renamed; the contract stays 27 events, and the
addition is the direction 0040's st-ii9v amendment calls non-breaking.
On the bridge side the value is never flattened into span attributes -
it is an opaque in-VM term the bridge uses (to parent or link) rather
than exports, the same line docs/opentelemetry.md already draws for
metadata.effect.
5. The recording carries the field; nothing strips it. Three arguments, in order of force:
- Replay soundness requires it. The live core copies
event.caller_contextinto%SendDelayed{}/%Cancel{}(decision 3), so the live effect stream contains the value. Replay reproduces that stream only if the recorded events still carry the input the copy reads. A stripped recording would replay to a different effect stream - precisely the failure ADR-0034's byte-identical claim exists to rule out. Carrying is not a convenience; it is what keeps the recording a sound input set under ADR-0029. - Stripping would be new code. The appenders store the
%Event{}as given today; the carry direction is the zero-code direction at record time. - The serializability question is already answered. ADR-0057's
Consequences place host-supplied terms inside recorded payloads -
atoms and worse in event
data- under the host's own:safeand pid-free obligation, with the codec neither scanning nor translating.caller_contextjoinsdataunder exactly that existing rule: an OTel span context is plain data and round-trips fine; a host that stows a pid or a fun in the slot loses persistability of that recording the same way it would throughdata, and the library owes it nothing new.
One format consequence follows. %Statifier.Event{} gaining a defstruct
key changes the shape of the structs inside a recording blob's
entries: a blob written before the field decodes to event maps missing
:caller_context, and reading such a map as the new struct is exactly
the silent misread ADR-0057 decision 4's obligation names.
Statifier.Session.Recording's @format_version therefore bumps
2 -> 3 on the implementing branch, and this record blesses the same
default ADR-0059 blessed for version-1 positions: the decoder should
read version-2 blobs and default caller_context: nil onto each stored
event on import, which is safe exactly because a version-2 blob predates
the field - no context was ever attached to the events it holds.
Statifier.Position and Statifier.Chart are untouched:
%MachineState{}'s caller_context is transient per-macrostep state,
a position is written at quiescence where no macrostep is open and the
value attributes nothing, so it stays out of Position's export and no
position bump happens. The chart did not change shape.
6. ADR-0054's keys are untouched. caller_context joins neither the
eight-component dedup key nor the {session scope, send_id}
cancellation key: an opaque host term has no business in a key (it is
not comparable across hosts, not bounded, and not replay-relevant to
identity), and ADR-0059's ordinal already made the dedup key
per-instance. A durable store carries the value as row data beside the
key components, reading it back at firing or cancellation time.
7. Deliberately deferred, with the trigger named. Session.cancel/1
and Session.interpret/2 accept no context: their macrosteps (trigger
:cancel) and the :interpret event carry caller_context: nil until
a bridge consumer asks for attached cancellation or injection spans. The
addition would be additive on the same terms as this record - an opts
keyword on the public function, a metadata key already present - so
deferring costs nothing and commits nothing.
Consequences
- The implementation is sized separately on this bead's follow-on, per
ADR-0046/0059's precedent (this record changes no code): the field,
@typeline, and moduledoc paragraph onStatifier.Event,Statifier.Effect.SendDelayed, andStatifier.Effect.Cancel;Event.external/2's opt;%MachineState{}'s transient field with its three writers and the two effect-constructor reads;Session.Effects.delivered_event/2/internal_event/1's copy; the four telemetry metadata clauses andStatifier.Session.Telemetry's contract table; the recording format-version bump2 -> 3with the version-2 import default and its round-trip tests; and the test/support literal builds. - Documentation edits are directed to the implementing branch, the
ADR-0049 decision 6 pattern:
docs/opentelemetry.md's session-process caveat paragraph is rewritten from "until that field exists" to the field's name and read points;docs/durable-timers.md's field table gains thecaller_contextrow (row data, never a key component);docs/persistence.md's recording section notes the version bump and the host-term obligation the slot inherits fromdata. None of these may land beforelib/carries the field, so none land with this record. - A changelog fragment is owed on the implementing branch, not this
one (ADRs alone get none, per
changelog.d/README.md): the public additions and the recording format bump are exactly the ADR-0061 decision 3 surface, and the version-2-read default keeps the bump from breaking blobs written against an earlier pin. - The firing-side half - restoring the context and emitting span links
when a stored timer fires or a stored cancel is processed - belongs
to
statifier_oban's tracker. As of this record that mirror bead does not exist: this bead'smirrors:line names an id (sob-v28) that does not resolve in that tracker, and filing the statifier_oban half is a human ask no agent may make (this repo's cross-repo rules). Until it is filed, the in-process firing chain (decision 3'splan/2copy) is the only implemented consumer of the slot at firing time. - Once
opentelemetry_statifierships against the amended shapes, the four metadata keys join the ADR-0040 freeze: removing or renaming one is a breaking change to a real consumer. - What would reopen this record: a third effect becoming durably stored
(it claims the same stamp, as it claims
ordinal); a consumer needing the context on internal-round granularity rather than macrostep granularity (decision 3's overwrite rule is where that argument lands); the deferredcancel/1/interpret/2trigger in decision 7 firing; or the library ever needing to read the value, which would contradict decision 1 and ADR-0062's opacity constraint rather than extend them.
Related
- ADR-0062 (decision 4 names this field; the bridge consumes only public events)
- ADR-0040 (the event contract this record amends in part; st-ii9v's additive-is-non-breaking direction)
- ADR-0059 (the durable-timer-effects precedent: fold-state stamping, both-effects symmetry, the off-every-other-effect rule, the old-version import default)
- ADR-0046 ("the stamp is the core's, not the session's")
- ADR-0034 / ADR-0029 (replay re-drives the fold from recorded inputs; why the recording must carry the field)
- ADR-0057 (recording envelope and format-version obligation; the host-term serializability rule the slot inherits)
- ADR-0054 (the durable-timer vocabulary; keys untouched here)