Story-1439 · pramaan-docs ADR-124 · Agents lane

Atlas joins the broker trace before the brain thread starts

Branch feature/1439-ag-extract-parent · Story: pramaan-io/pramaan-spec#1439 · Lockstep: functions #1438 injects agent_invoke.v1 meta.traceparent

Why this exists

The broker already owns the run. Atlas owns only the brain. For observability, that means Atlas must not invent a new trace when AgentCore invokes it. It must accept the broker's W3C traceparent, then let Pydantic AI's own model span become a child in the same connected server-side trace graph.

The dangerous shortcut is using run_id as a trace key. That would make logs searchable but would not create parent-child trace continuity. pramaan-docs ADR-124 is stricter: technical request identity is trace_id; run_id remains product truth.

What changed

agent_invoke.v1 meta
  traceparent / tracestate
  run_id / form_id / firm_id
          |
          v
runtime.stream_invoke()
  extract traceparent BEFORE contextvars.copy_context()
  rehydrate baggage allowlist: run_id, form_id
  bind firm_id/model_id/agent.key in local context
          |
          v
atlas-brain thread
  Pydantic AI opens its library-owned gen_ai model span
  span trace_id == broker trace_id
  span attrs include content-free PRAMAAN pivots
SurfaceImplementation
runtime.pyCalls agent_invoke_observability_context(...) before contextvars.copy_context(), so the copied brain-thread context carries the remote parent.
observability.pyOwns extraction, allowlisted baggage rehydration, local firm/model context, the Logfire-lane TracerProvider(id_generator=AwsXRayIdGenerator()), and T2 span enrichment.
agent.pyBinds model_id/agent.key around each Pydantic AI call without wrapping model calls in manual spans.
Static guardKeeps X-Ray SDK APIs banned, but allows the OTel AwsXRayIdGenerator token only in agents/atlas/observability.py.

Semconv split

Standard keysCustom keys
service.name, deployment.environment.name, Pydantic AI's content-free gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.usage.*, plus gen_ai.agent.name=Atlas.agent.key=atlas, firm_id, run_id, form_id, model_id. firm_id is never baggage; only run_id/form_id are baggage.
Content capture remains off. The implementation does not emit real matter text via gen_ai.input.messages, gen_ai.output.messages, or gen_ai.system_instructions.

Proof

The new regression test creates a real OTel provider with in-memory export, injects a W3C traceparent, copies context into a separate thread, runs a real Pydantic AI TestModel, and asserts the first gen_ai.operation.name=chat span has the injected trace id plus the expected content-free attributes.

Verification: ruff check tests/ scripts/ agents/atlas, uv run pytest tests -q in agents/atlas, root pytest tests -q, scripts/check_brain_egress.py, and git diff --check all pass.