Story-1439 made Atlas join the broker's trace, and Pydantic AI already emits a content-free gen_ai model span per call (T1). But a model span alone cannot say which reasoning pass spent that call. When intake, the self-audit, and a reframe all hit the same model, the trace is a flat list of look-alike model spans with no domain shape.
The dangerous shortcut is a span per function — every callable gets a tracer call, the trace explodes, and the registry stops meaning anything. pramaan-docs ADR-124 is stricter: a manual span marks a registry-listed domain operation only (T3), opened through the one shared helper, carrying only low-cardinality content-free dimensions.
broker remote parent (traceparent, Story-1439)
|
v
agents.atlas.invoke INTERNAL (runtime.py — the one parent)
+-- agents.atlas.context_extract INTERNAL shape request -> typed context
+-- agents.atlas.intake_validate INTERNAL wizard -> frame (spans retries)
| +-- gen_ai model span (Pydantic AI, T1)
+-- agents.atlas.compose_frame INTERNAL finalize proposal counsel sees
| +-- agents.atlas.reflect INTERNAL flag-gated self-audit
| +-- gen_ai model span (T1)
+-- agents.atlas.explain / reframe / evaluate (other invocation modes)
+-- gen_ai model span (T1)
| Surface | Implementation |
|---|---|
observability.py | New atlas_pass_span(...) — an approved tracer-helper file under the R12 gate. Resolves the closed-registry spec, reads ambient run dims (firm_id span-local ContextVar, run_id/form_id baggage, model_id ContextVar), stamps only approved keys, delegates to the shared manual_span helper, and fails open (no-op) when run dims are unbound. |
runtime.py | Opens the agents.atlas.invoke parent around the whole invocation; wraps context_extract and compose_frame in the chat path. |
agent.py | Wraps the model-call passes from the registry: intake_validate, reflect, explain, reframe, and evaluate (staleness). Outcome flips to error on each pass's own fail-closed path. |
| R12 gate | No raw tracer call leaves the helper files: runtime.py/agent.py only call atlas_pass_span and span.set_attribute. scripts/check_brain_egress.py stays clean. |
agents.atlas.plan and agents.atlas.retrieve_evidence. Atlas v1 has no planning step and no tool/evidence retrieval (both forbidden in v1), so emitting them would be fabricated telemetry. They stay in the closed enum for the future reasoning loop. staleness maps to evaluate; its model call is also covered by T1.| Standard keys (unchanged, reused) | Custom product keys (on the manual spans) |
|---|---|
service.name=pramaan-agents-atlas, 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.*, gen_ai.agent.name=Atlas. This PR adds no new standard semconv keys. | agent.key=atlas, firm_id, run_id, form_id, model_id, outcome — all from the merged closed allowlist (AGENT_ALLOWED_SPAN_ATTRIBUTES). No invented equivalents. firm_id is a span attribute only, never baggage; only run_id/form_id ride baggage. |
The new test module tests/test_atlas_domain_spans.py points the manual-span helper at a live in-memory OTel tracer and reads back exported spans: it asserts the chat path opens invoke + context_extract + compose_frame with the two passes nesting under invoke (same trace, parent span id), that intake_validate records outcome=ok on success and error on a fail-closed run, that reflect/explain/reframe/evaluate each emit their INTERNAL registry span with the content-free dims, and that a pass driven with no invoke context emits no span yet still returns its safe value.
ruff check agents/atlas, pytest tests -q in agents/atlas (192 passed + 5 new), root pytest tests/test_brain_egress_check.py -q (45 passed), python scripts/check_brain_egress.py (clean), and git diff --check all pass.