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

Atlas's reasoning passes become INTERNAL children of one invoke span

Branch feature/1441-atlas-spans · Story: pramaan-io/pramaan-spec#1441 · Parent: EPIC-1400 Phase-2 (roster R4b)

Why this exists

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.

What changed

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)
SurfaceImplementation
observability.pyNew 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.pyOpens the agents.atlas.invoke parent around the whole invocation; wraps context_extract and compose_frame in the chat path.
agent.pyWraps 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 gateNo 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.
Registry-reserved but NOT emitted: 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.

Semconv split

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.
Content capture stays off. No prompt, evidence, or legal text is attached to any manual span — only the approved low-cardinality dimensions above.

Proof

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.

Verification: 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.