# Under what conditions must an instance refuse to propagate a parent span context across a federation boundary **Date of Record:** 2026 November 8 **Scope:** Applies to distributed trace correlation in systems where autonomous agents hand off work to sub-agents and where spans cross administrative federation boundaries (separate trust domains, separate trace backends, or separate tenants). Does not apply to single-domain, single-tenant traces where all spans share one collector and one signing authority. ## Core Distinction There are two distinct things commonly conflated under "propagating trace context": 1. **Correlation propagation** — carrying a `trace_id` and a parent `span_id` so that downstream spans can be stitched into the same trace tree. 2. **Authority propagation** — asserting that the downstream span is *causally owned* by the upstream span and therefore inherits its trust level, sampling decision, and retention policy. Within a single trust domain these are the same operation. Across a federation boundary they are not. A sub-agent in federation B can legitimately *correlate* to a parent span in federation A (share the trace_id for observability) while it must *not* inherit A's sampling decision, baggage, or trust assertions. The failure mode is treating the second as an automatic consequence of the first. The non-obvious invariant: **a trace_id is a correlation key, not a capability.** Any system that lets possession of a parent span context confer authority (e.g., "this span is sampled because the parent was sampled," or "this span carries the parent's `baggage` headers into a different tenant") has a confused-deputy vulnerability in its telemetry plane. ## What Was Observed In a nested handoff chain — orchestrator agent A (federation `alpha`) → planner agent B (federation `beta`) → tool-executor C (federation `gamma`) — we observed the following during a cross-federation incident review: - The `trace_id` propagated correctly through all three hops (W3C `traceparent` header passed verbatim). - The **sampling decision** propagated with it. A's tail-based sampler decided to drop the trace; because the decision was encoded in the `traceparent` trace-flags byte and B and C honored it, the entire downstream execution was unsampled. When the incident occurred in C, there was no trace for it. The parent's sampling authority had silently overridden the child federation's own sampling policy. - Worse: `baggage` headers set by A (containing an internal `alpha` tenant identifier and a debug flag) were forwarded unchanged into `gamma`. `gamma`'s log enricher wrote the `alpha` tenant id into `gamma`'s audit log, producing cross-tenant attribution errors. - In a second case, a *replayed* `traceparent` from a stale agent B instance (restarted with an old context) linked a new C span to a parent span that had already been closed and exported. The resulting trace was a "dangling parent" — the child pointed at a span_id that existed in `alpha`'s backend but was 40 minutes old, corrupting latency calculations (negative durations after clock-skew correction). The common thread: **the parent context was treated as authoritative across a boundary where authority was not established.** ## Condition of Validity Propagating a parent span context across a federation boundary is valid if and only if all of the following hold: 1. **Trust is explicit, not implied.** The receiving federation has a configured, verifiable trust relationship with the sending federation (mutual TLS with pinned identities, or a signed context envelope). Possession of a `traceparent` string is not evidence of trust. 2. **Sampling authority is re-decided locally.** The receiving federation MUST run its own sampling decision and MUST NOT treat the incoming trace-flags as binding. It may *hint* (e.g., "upstream wanted this sampled") but the local policy decides. A dropped-upstream trace must still be samplable downstream if local policy says so. 3. **Baggage is filtered, not forwarded.** Only baggage keys explicitly allow-listed for the destination federation may cross. Tenant identifiers, debug flags, and PII MUST be stripped at the boundary. 4. **Causality is bounded in time.** The parent span must be within a configured freshness window (we use 5 minutes) and must not have been previously closed/exported. Stale contexts are correlation *hints*, not parents. 5. **The trace_id is preserved for correlation but the parent linkage is recorded as "cross-federation" with the originating federation id**, so backends can distinguish intra-domain parent-child from federated correlation edges. If any condition fails, the instance must **not** propagate the parent context as authoritative. It may still emit a span with the same `trace_id` and a `link` (OpenTelemetry span link) to the remote parent, but with `parent_span_id = null` and a fresh local root. ## Decision Boundary The decision to propagate vs. to link-and-root is a function of (a) whether the boundary is a trust boundary and (b) whether the incoming context is fresh and signed. | Boundary type | Context fresh & signed | Context stale / unsigned | |---|---|---| | Same trust domain | Propagate as parent | Link only; log anomaly | | Cross-federation, trusted peer | Propagate trace_id; **re-decide sampling**; filter baggage; parent link allowed | Link only; do not adopt parent | | Cross-federation, unknown peer | **Refuse parent propagation**; start new root; link if trace_id present | Refuse; start new root; drop trace_id | The critical cell is "cross-federation, unknown peer, fresh & signed-looking." A signed-looking context from an unknown peer is exactly the confused-deputy case. The instance must refuse. The error is *not* "I cannot correlate" — it is "I refuse to accept authority I cannot verify." This is the condition the title asks about: **an instance must refuse to propagate a parent span context across a federation boundary when the receiving side cannot verify the sending side's authority to make sampling, baggage, or causality assertions — regardless of how well-formed the context appears.** ## Operational Effect Enforcing this boundary has three concrete effects: - **Trace trees fragment at federation edges.** Downstream federations produce their own roots, linked to upstream via span links rather than parent-child. This is correct: the causal claim is "related to," not "owned by." Dashboards must be taught to follow links, not just parent pointers. - **Sampling becomes per-federation.** A trace can be sampled in `gamma` and unsampled in `alpha`. Cross-federation trace completeness is no longer guaranteed and must be reported as a coverage metric, not assumed. - **Stale-context replays are contained.** A restarted agent replaying an old `traceparent` produces a *link* to a closed span, which backends can render as a dotted edge, rather than a parent-child edge that corrupts latency histograms. The cost is operational complexity: two correlation mechanisms (parent pointers intra-domain, links inter-domain) and a trust-config surface. The benefit is that the telemetry plane stops being a lateral-movement path and a source of silent sampling-policy override. ```python from __future__ import annotations import time from dataclasses import dataclass from typing import Optional class UntrustedFederationContextError(RuntimeError): """Raised when a parent span context cannot be propagated as authoritative.""" @dataclass(frozen=True) class SpanContext: trace_id: str span_id: str federation_id: str sampled: bool baggage: dict[str, str] created_at: float # epoch seconds signed: bool @dataclass(frozen=True) class TrustPolicy: trusted_peers: frozenset[str] allowed_baggage_keys: frozenset[str] max_context_age_s: float = 300.0 def decide_propagation( ctx: SpanContext, local_federation: str, policy: TrustPolicy, now: Optional[float] = None, ) -> dict: """ Decide how to handle an incoming parent span context at a boundary. Returns a dict describing the resulting local span's linkage: - mode: "parent" | "link" | "root" - parent_span_id: str | None - trace_id: str | None - baggage: filtered baggage - sampled: local sampling decision (never inherited across boundary) """ now = time.time() if now is None else now cross_boundary = ctx.federation_id != local_federation # Intra-domain: trust is implicit, propagate as parent. if not cross_boundary: return { "mode": "parent", "parent_span_id": ctx.span_id, "trace_id": ctx.trace_id, "baggage": dict(ctx.baggage), "sampled": ctx.sampled, } # Cross-boundary: authority must be verified. if ctx.federation_id not in policy.trusted_peers: raise UntrustedFederationContextError( f"refusing parent propagation: federation {ctx.federation_id!r} " f"is not a trusted peer of {local_federation!r}" ) if not ctx.signed: raise UntrustedFederationContextError( f"refusing parent propagation: context from {ctx.federation_id!r} " f"is unsigned; possession of traceparent is not authority" ) age = now - ctx.created_at if age > policy.max_context_age_s: # Stale: correlate via link, but never adopt as parent. return { "mode": "link", "parent_span_id": None, "trace_id": ctx.trace_id, "baggage": {}, "sampled": None, # local sampler decides "link_reason": f"stale_context age={age:.0f}s", } # Fresh, signed, trusted peer: correlate, but do NOT inherit authority. filtered_baggage = { k: v for k, v in ctx.baggage.items() if k in policy.allowed_baggage_keys } return { "mode": "link", "parent_span_id": None, # federated edge is a link, not a parent "trace_id": ctx.trace_id, "baggage": filtered_baggage, "sampled": None, # local sampling policy re-decides "link_reason": "cross_federation_correlation", } def ingest_remote_context( ctx: SpanContext, local_federation: str, policy: TrustPolicy, ) -> dict: """Entry point used by the span processor at a federation boundary.""" decision = decide_propagation(ctx, local_federation, policy) if decision["mode"] == "link" and decision.get("sampled") is None: # Local sampler runs here; placeholder for policy hook. decision["sampled"] = True return decision # Example of an explicit refusal path used by a stricter caller: def require_trusted_parent(ctx: SpanContext, local_federation: str, policy: TrustPolicy) -> SpanContext: if ctx.federation_id != local_federation and ctx.federation_id not in policy.trusted_peers: raise UntrustedFederationContextError( f"cannot adopt parent from untrusted federation {ctx.federation_id!r}" ) return ctx ```