One trace_id, both lanes

A single agent run is observed through two telescopes at once: the GENERAL lane (CloudWatch + X-Ray via the ADOT layer — "what did the runtime do") and the LLM lane (Pydantic Logfire — "what did the model do, at what token cost"). When a lawyer's request goes wrong, an on-call engineer needs ONE id that opens BOTH. Story-1843 makes the brain continue the upstream trace_id in both lanes and stamps run_id on every span in each — so a broken run is one search, not two disconnected ones.
Story-1843 · pramaan-docs ADR-146 (Foundation) · ADR-118 (two lanes) · ADR-104 (one tracing system) · ADR-129 · ADR-124 §D10 (baggage) · ADR-110 (thin brain) · RFX atlas-traceid-logfire · branch issue-1843-trace-id-continuity · 2026-06-28

The broker (pramaan-functions src/clerk_worker/runner.py) already injects W3C trace context as meta.traceparent on the agent_invoke.v1 envelope, and the LLM lane already adopts it: agents/atlas/observability/identity.py's agent_invoke_observability_context extracts the carrier and attaches it before contextvars.copy_context(), so the copied brain-thread context carries the remote parent (Story-1439 / 1463 proved the Logfire-lane spans join it). The GAP: nothing taught the GENERAL lane the same trick, and the run_id dimension landed on a general-lane span only when a manual_span happened to pass it explicitly.

The naive fix that detonates: "just extract traceparent again in the general lane and start a root span from it." That re-implements context propagation a second time, fights ADOT for the global provider, and risks a set_tracer_provider override (forbidden — pramaan-docs ADR-104, ONE tracing system). The trace continuity already rides the single attach + copy_context() in stream_invoke — it is lane-agnostic OTel context, so the general lane inherits it for free. The only thing actually missing was the ambient enricher on the global provider.

1 · The one missing wire — the enricher on the global provider

The LLM lane gets the converged AmbientIdentitySpanProcessor on its dedicated provider (in configure_observability). The general lane — the ADOT-owned global TracerProvider — never got it. observability/identity.py adds the one wiring function that fixes that, without ever owning or overriding the global provider.

# agents/atlas/observability/identity.py
def configure_general_lane_identity(provider=None) -> bool:
    # enrich the EXISTING global provider; never construct, never set_tracer_provider (ADR-104)
    target = provider or _otel_trace.get_tracer_provider()
    add = getattr(target, "add_span_processor", None)
    if not callable(add):           # API default proxy (no ADOT) -> clean no-op (local/CI)
        return False
    add(AmbientIdentitySpanProcessor())   # the SAME single converged enricher the LLM lane uses
    return True                     # idempotent (warm-container guard), never raises

Now run_id / firm_id / form_id / model_id / agent.key land on every general-lane span at on_start — including ADOT auto-instrumented spans the brain never touches — sourced exactly as ADR-124 §D10 requires (run_id/form_id from allowlisted baggage; firm_id from the process-local ContextVar, never baggage). main.py calls it once at cold start, unconditionally (not gated on LOGFIRE_TOKEN): the general lane is correct even with the Logfire lane off.

2 · Why trace continuity already holds in both lanes

It is the same attach for both. runtime.py stream_invoke wraps the context capture so the remote parent rides into the brain thread; the general-lane manual_span (via the global tracer) and the LLM-lane Pydantic-AI span both create children of that one attached parent — so both inherit the upstream trace_id.

# agents/atlas/runtime.py — ONE extraction, both lanes ride it
with agent_invoke_observability_context(meta, model_id=config.model_id):
    ctx = contextvars.copy_context()   # remote parent now in the copied brain-thread context
brain = threading.Thread(target=lambda: ctx.run(_pump), ...)   # both lanes' spans descend from it
LaneProviderAdopts upstream tracerun_id on every span
LLM (Logfire)dedicated, X-Ray id genvia attach (Story-1439)enricher on dedicated provider
GENERAL (ADOT/X-Ray)global (ADOT-owned)via the SAME attachenricher on global provider (this Story)

3 · The no-parent fallback is preserved, not replaced

Scope line: a direct invoke with no inbound traceparent must still mint an X-Ray-format (epoch-prefixed) root, so an orphan run still joins the X-Ray/CloudWatch lane. The enricher only stamps attributes at on_start — it touches no id_generator. The general lane's root id generator is ADOT's own AwsXRayIdGenerator (the dedicated LLM lane already builds its own). Nothing in this Story changes id minting, so the fallback is untouched by construction.

4 · Proof — offline, deterministic, no key, no egress

agents/atlas/tests/test_atlas_observability.py gains four tests mirroring the existing #1439/#1463 harness (real OTel SDK + in-memory exporters + the broker→AgentCore thread hop; no network):

# both lanes share ONE trace_id == the injected parent, run_id on EVERY span
root = ATLAS_INVOKE  # general lane (manual);  auto = adot-auto-span  # general lane (raw tracer, NO explicit run_id)
chat = pydantic-ai chat span  # LLM lane
assert root.trace_id == parent and auto.trace_id == parent and chat.trace_id == parent  # ✓ one id, both lanes
assert auto.attributes["run_id"] == "run-1843"   # ✓ stamped by the enricher, not an explicit pass

# no inbound traceparent -> general-lane root keeps the X-Ray-format fallback
_assert_xray_format_trace_id(root.trace_id, captured_at=now)   # ✓ epoch-prefixed, not W3C-random

The adot-auto-span is the sharp proof: it is created through the raw global tracer with no explicit attributes, so its run_id can come only from the enricher on the global provider — exactly the gap this Story closes. Two focused tests pin the wiring itself (added once, idempotent; clean no-op when ADOT is absent / the provider has no add_span_processor).

5 · Verify gate

ruff clean on agents/atlas and tests/ scripts/. pytest 618 passed in agents/atlas (4 new + existing) and 146 passed governance. brain-egress R12 gate clean — no new egress, no baggage write outside the helper, no set_tracer_provider, no provider construction or X-Ray-SDK outside the bootstrap. Observability ONLY: the user-facing answer path is byte-identical (pramaan-docs ADR-110 thin brain).

One-sentence recall: the upstream trace already rode one attach into the brain thread for both lanes — Story-1843 adds the missing ambient enricher to the ADOT global provider so run_id lands on every general-lane span too, making one trace_id open both telescopes while the no-parent X-Ray-format fallback stays untouched.