Story-1449 pramaan-admin EPIC-1400 trace graph migration (Phase-2)

The browser can finally name the trace it lives in

Story-1448 gave the admin runtime a real OpenTelemetry provider, so server-side spans now join the edge-rooted trace. But the trace_id still stopped at the server: the browser only knew its correlation_id. A staff member hitting an error, or a support engineer opening a firm's trace, had no way to hand back the exact trace_id the X-Ray graph keys on. This Story closes that gap on the read side and lays the write-side foundation: surface the request trace_id to the page, and add the one approved home for admin domain spans.

What changed

Two moves, both small and both load-bearing. First, the root layout reads the traceparent header that src/proxy.ts already mints and forwards, parses out the 32-hex trace_id, and stamps it into a <meta name="trace_id"> tag — exactly mirroring how correlation_id has always been surfaced. The existing browser diagnostics initializer then mirrors that value onto window.__pramaan.traceId, so bug reports, support tooling, and DevTools all read the same identity the server-side trace graph uses. Second, a new server-only helper, src/lib/observability/manual-span.ts, becomes the single sanctioned place that may call the raw tracer.

trace_id reaches the browser

layout.tsx parses the inbound traceparent via the frozen parseTraceparent bridge util and renders <meta name="trace_id"> only when present. With the kill switch PRAMAAN_OTEL_DISABLED=1 the header is stripped, so the meta is simply absent — never a blank tag.

window.__pramaan.traceId

initializePramaanDiagnostics now mirrors traceId next to correlationId, reading the prop threaded through CorrelationBanner or falling back to the meta tag. Absent trace context leaves the field unset rather than blanking it.

One home for raw spans

withManualSpan is the only product-code caller of startActiveSpan. Span names come from a closed AdminSpanName enum matching the ADR-124 registry; attributes are filtered to a low-cardinality allowlist; firm_id is a span attribute only, never baggage.

The closed registry, honored exactly

Per pramaan-docs ADR-124, manual spans may only use frozen names. The helper exposes exactly the four admin rows of the registry — admin.trace.lookup, admin.bug_report.review, admin.support.trace_open, and admin.generated_client.call — each SpanKind.INTERNAL, with attributes restricted to the registry's required keys (firm_id, http.method, http.route). The helper itself is instrumentation infrastructure, not a business boundary, so it is correctly instrumentation-exempt and ships with no callers yet; the bug-report, support-trace, and generated-client flows that will call it are later Phase-2 tickets. Anything off-registry needs a doctrine amendment before it can ship.

The guardrail is enforced, not just documented

ADR-124 requires CI to reject raw tracer calls outside approved helper files. A new static test, tests/unit/static/manual-span-semconv.test.ts, does three things: it scans src/ for startActiveSpan / start_span / tracer.* and fails on any hit outside the two approved files (src/instrumentation.ts, the manual-span helper); it asserts every span name the helper declares is in the ADR-124 admin registry; and it asserts the attribute allowlist is a subset of the registry's approved keys. A dedicated CI step, check:semconv, runs it as a named gate alongside the existing typecheck, lint, and codegen checks.

Verification proof

Gates green: pnpm exec tsc --noEmit (exit 0), pnpm run lint (0 errors; 7 pre-existing warnings in unrelated test files), pnpm run build (compiled successfully, all 18 routes), and pnpm run test:unit (547 passed across 87 files, including the new semconv guardrail test).

Guardrail check: pnpm run check:semconv passes — no raw tracer calls outside the two approved files, span names and attribute keys all in-registry. git diff --check clean.

Contract integrity: this change touches no contract surface — src/gen/ is untouched, so codegen freshness is unaffected.

Scope honored: no manual spans added in proxy.ts or edge middleware (Vercel Edge custom spans are not forwarded); no TracerProvider constructed (the provider stays owned by registerOTel in src/instrumentation.ts from Story-1448); firm_id never enters baggage.