Skip to content

[sdk-client S6] feat(sdk): express trace propagation as a general contribution #1353

Description

@rickylabs

Important

0.0.7 normative scope amendment — 2026-08-13. RFC 0001 Stage 5 reverses the historical
implementation proposal below. Do not ship traceContextContribution() and do not move final
trace injection out of the transport. Prove instead that the transport creates the client span and
remains the sole final author of trace headers, contributor declarations of reserved trace keys are
rejected, retries/reconnects preserve correct span topology, and composed contributions cannot
double-inject or overwrite traceparent/tracestate.

Summary

A contribution seam validated by one consumer encodes that consumer's shape. Auth (#1352) is
credential-shaped; without a structurally different second consumer, RFC-A's claim that the chain is
general is unproven. Trace-context propagation is the right second consumer because it is already
hard-coded inside createHttpClientLink behind a boolean, with a per-call override field and
existing regression coverage — so re-expressing it as a contribution is a migration, not an
addition
, and the negative test therefore has teeth: remove the contribution and traceparent must
disappear from the wire. It also removes the framework's last private fast lane through the link, so
NetScript's own default path goes through the public composition path rather than beside it.

Evidence

  • Corpus: research/external/orpc.md §1.5, §5 (the "header/trace/tenant contribution from a plugin"
    row), §7 item 1; research/repo-audit/services-sdk.md §1.3, §2.3 (losing the client span is the
    documented cost of the current escape hatch), §3.1.
  • Source at baseline fac9e339042c (re-verified for this draft):
    • packages/sdk/src/client/http-client-link.ts:82-101 — the headers callback: Content-Type,
      then traceparent/tracestate from options.context.traceHeaders if present, else from
      getTraceHeaders(). Gated by the propagateTraceContext boolean.
    • packages/sdk/src/client/service-client.ts:41-49,55-64propagateTraceContext = true default,
      threaded into the link along with getTraceHeaders.
    • packages/sdk/src/ports/service-client.ts:149-155ServiceClientContext.traceHeaders is the
      per-call override, i.e. this concern already exercises the exact "typed per-call context" axis
      the envelope claims to generalise.
    • packages/sdk/src/client/http-client-link.ts:127+ — the custom fetch opens the CLIENT span
      with rpc.system=orpc / server.address attributes; this is the behaviour a hand-rolled client
      silently loses today.
    • packages/sdk/src/presets/define-services.ts:106-116propagateTraceContext is forwarded from
      L3, so the migration must preserve the L3 surface too.
  • Rejected alternative, with its own citations: the AI/streams header contribution
    (getStreamsAuth(){ Authorization: 'Bearer ' + STREAMS_SECRET },
    packages/plugin-streams-core/src/application/stream-url-resolver.ts:136-150, consumed at
    packages/fresh/src/runtime/streams/create-stream-db.ts:111 and
    packages/fresh/src/runtime/ai/stream-proxy.ts:162).

Current surface

Trace propagation works and is not a defect — it is a hard-code. Two of its three axes
(header authorship, per-call typed override) are exactly the axes RFC-A's envelope exists to
generalise, and the third (server-side context resolution) is the environment boundary. Meanwhile
the only other credential-shaped header seam in the repo, getStreamsAuth(), was built entirely
outside the typed client with raw fetch, because there was nowhere else to put it.

Target contract

  1. traceContextContribution() ships from @netscript/sdk (or @netscript/telemetry, per the
    layering decision recorded in the implementing PR) as an SdkClientContribution declaring
    headerKeys: ['traceparent', 'tracestate'] and per-call context
    { trace?: { traceparent?: string; tracestate?: string } | null }.
  2. It is composed by default. createServiceClient prepends it when propagateTraceContext is
    not false, so the shipped default behaviour and the L3 defineServices surface are unchanged.
  3. The link body no longer authors trace headers. After this issue,
    createHttpClientLink contains no traceparent literal; ServiceClientContext.traceHeaders
    becomes a @deprecated alias forwarding to the contribution's context field for one minor.
  4. It carries no secret and needs no server-only variant for header authorship, which is
    precisely what makes it a generality proof rather than a second credential test.
  5. The CLIENT span stays attached to the framework default chain, so composing extra
    contributions cannot silently drop it.

Why this consumer, and not the AI/streams headers

Both candidates were evaluated (RFC-A §9). getStreamsAuth() is attractive because it is a real,
shipped, out-of-band header that today lives outside the typed client. It was rejected as the
second consumer for three reasons: it is still a credential (a process-global shared secret),
so it re-tests auth's axis rather than a new one; it is entangled with the streams/SSE transport and
with #1329's envelope work, so a failure would not distinguish "the seam is wrong" from "streams is
wrong"; and it is purely additive, so a decorative seam could pass its test. Trace context fails
loudly if the seam is decorative, because its behaviour already exists and must survive the move. The
streams/AI header contribution remains a good third consumer once the streams envelope settles.

Acceptance

  • Transport remains the sole final author of traceparent/tracestate; no trace
    SdkClientContribution or public trace contribution factory is introduced.
  • The [sdk-client S3] feat(sdk): expose the typed oRPC client-contribution seam #1349 descriptor chain cannot overwrite reserved trace headers, observe resolved trace
    values, or bypass the transport's propagation switch.
  • Existing default/true/false propagateTraceContext behavior and per-call override behavior are
    preserved on the wire.
  • The CLIENT span retains rpc.system=orpc and server.address when unrelated header/auth/cache
    contributions compose.
  • NEGATIVE: duplicate/reserved header attempts fail deterministically; disabling propagation emits
    neither trace header; removing tracing from the transport makes the regression red.
  • Auth and tracing compose in both descriptor orders without lost credentials or trace headers,
    while trace ownership never moves to the contribution layer.
  • Root check, test, and publish dry-run pass.

Boundaries

Docs/consumer proof

The SDK reference documents trace propagation as a contribution — the first place a reader sees the
same mechanism used twice, by two unrelated concerns, which is the whole adoption argument. Consumer
proof is a diff: http-client-link.ts loses its trace block, @netscript/sdk's public surface gains
one small value, and the two-contribution composition example in the docs is the one an app author
copies. If the negative gate ("remove it and the header disappears") cannot be written, the seam is
decorative and RFC-A's ratification should be reconsidered rather than the test weakened.

Provenance

Seed run plan-fable5-remediation-roadmap--seed, PR #1347, 2026-08-08. Consumer choice and its
rejected alternative are recorded in rfcs/RFC-A-sdk-client-composition.md §9; sourced from
research/external/orpc.md §5/§7 and research/repo-audit/services-sdk.md §1.3/§2.3, with all cited
lines re-verified against worktree baseline fac9e339042c. No GitHub mutation was performed.


Filed from planning seed PR #1347 · source Draft-ID T1-06 · live issue #1353.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions