From 1863087b6c60896d3c5bb4ee58eb7de5d98e5434 Mon Sep 17 00:00:00 2001 From: bdj Date: Mon, 20 Apr 2026 13:49:07 -0700 Subject: [PATCH 1/3] feat: ProtocolSettlement sends X-ATXP-App-Name header to /settle/* and /verify/* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets auth (see circuitandchisel/auth#254) attribute settlement observability events to the calling service — so Honeycomb/PostHog dashboards can slice revenue by llm / music / search / etc. Companion to the auth-side change that reads the header. ProtocolSettlement is used by two call paths: 1. Direct construction by callers (LLM today) 2. Hardcoded inside @atxp/express's middleware (tool servers) Both need to be able to set the app name. This PR plumbs it through both: - New ProtocolSettlementOptions with `appName?: string` as 5th constructor arg (options bag, backwards-compatible positional add). - New `appName?: string` field on ATXPConfig (exposed via ATXPArgs, so `atxpExpress({ appName: 'music' })` works). - atxpExpress forwards config.appName into `new ProtocolSettlement(...)`. Resolution order (documented on both surfaces): 1. explicit option value, if set to non-empty string 2. process.env.APP_NAME, if set to non-empty string 3. header omitted An explicit empty string disables the env fallback (useful in tests). The env fallback follows the Pattern-A precedent already established in the SDK (NODE_ENV → allowHttp / allowInsecureRequests in oAuthResource.ts, atxpFetcher.ts, atxpClient.ts, serverConfig.ts). Callers that already set APP_NAME for turtle's PostHog events get correct auth observability with zero code changes on SDK bump. appName added to BuildableATXPConfigFields alongside minimumPayment since both are optional fields that have no DEFAULT_CONFIG value. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/atxp-express/src/atxpExpress.ts | 1 + packages/atxp-server/src/protocol.test.ts | 125 +++++++++++++++++++++- packages/atxp-server/src/protocol.ts | 54 +++++++++- packages/atxp-server/src/serverConfig.ts | 2 +- packages/atxp-server/src/types.ts | 13 +++ 5 files changed, 190 insertions(+), 5 deletions(-) diff --git a/packages/atxp-express/src/atxpExpress.ts b/packages/atxp-express/src/atxpExpress.ts index 06091abb..7259b91c 100644 --- a/packages/atxp-express/src/atxpExpress.ts +++ b/packages/atxp-express/src/atxpExpress.ts @@ -126,6 +126,7 @@ export function atxpExpress(args: ATXPArgs): Router { logger, fetch.bind(globalThis), destinationAccountId, + { appName: config.appName }, ); // For X402: the credential's parsed payload contains `accepted` — the diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 10c62cb6..c8827934 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { detectProtocol, ProtocolSettlement } from './protocol.js'; describe('detectProtocol', () => { @@ -415,4 +415,127 @@ describe('ProtocolSettlement', () => { }); }); }); + + describe('X-ATXP-App-Name header', () => { + // Auth reads this header and attaches it to settle observability events + // so dashboards can slice by calling service. See auth#254. + const savedAppName = process.env.APP_NAME; + afterEach(() => { + if (savedAppName === undefined) delete process.env.APP_NAME; + else process.env.APP_NAME = savedAppName; + }); + + const okResponse = () => ({ ok: true, json: async () => ({ txHash: '0xabc', settledAmount: '1' }) }); + const credential = Buffer.from(JSON.stringify({ signature: '0xabc' })).toString('base64'); + + const headersFromFetch = (fetch: ReturnType) => + fetch.mock.calls[0][1].headers as Record; + + it('sends X-ATXP-App-Name when the explicit appName option is set', async () => { + mockFetch.mockResolvedValue(okResponse()); + const s = new ProtocolSettlement( + 'https://auth.atxp.ai' as any, + mockLogger, + mockFetch, + undefined, + { appName: 'llm' }, + ); + + await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); + + expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('llm'); + }); + + it('falls back to process.env.APP_NAME when appName option is omitted', async () => { + process.env.APP_NAME = 'music-mcp'; + mockFetch.mockResolvedValue(okResponse()); + const s = new ProtocolSettlement( + 'https://auth.atxp.ai' as any, + mockLogger, + mockFetch, + ); + + await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); + + expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('music-mcp'); + }); + + it('explicit appName option overrides process.env.APP_NAME', async () => { + process.env.APP_NAME = 'from-env'; + mockFetch.mockResolvedValue(okResponse()); + const s = new ProtocolSettlement( + 'https://auth.atxp.ai' as any, + mockLogger, + mockFetch, + undefined, + { appName: 'from-option' }, + ); + + await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); + + expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('from-option'); + }); + + it('explicit empty string disables env fallback (header omitted)', async () => { + // Empty-string override lets tests and oddball configs opt out of the + // env fallback without mutating process.env. + process.env.APP_NAME = 'would-have-used-this'; + mockFetch.mockResolvedValue(okResponse()); + const s = new ProtocolSettlement( + 'https://auth.atxp.ai' as any, + mockLogger, + mockFetch, + undefined, + { appName: '' }, + ); + + await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); + + expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-App-Name'); + }); + + it('omits the header when neither option nor env is set', async () => { + delete process.env.APP_NAME; + mockFetch.mockResolvedValue(okResponse()); + const s = new ProtocolSettlement( + 'https://auth.atxp.ai' as any, + mockLogger, + mockFetch, + ); + + await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); + + expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-App-Name'); + }); + + it('trims whitespace-only values to undefined (header omitted)', async () => { + mockFetch.mockResolvedValue(okResponse()); + const s = new ProtocolSettlement( + 'https://auth.atxp.ai' as any, + mockLogger, + mockFetch, + undefined, + { appName: ' ' }, + ); + + await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); + + expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-App-Name'); + }); + + it('sets the header on verify() as well as settle()', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ valid: true }) }); + const s = new ProtocolSettlement( + 'https://auth.atxp.ai' as any, + mockLogger, + mockFetch, + undefined, + { appName: 'llm' }, + ); + + await s.verify('x402', credential, { paymentRequirements: { network: 'base' } }); + + expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('llm'); + }); + }); }); diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index ae6a469f..7f50705f 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -171,18 +171,66 @@ export function parseCredentialBase64(credential: string): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.appName) headers[APP_NAME_HEADER] = this.appName; + return headers; + } /** * Verify a payment credential at request start. @@ -199,7 +247,7 @@ export class ProtocolSettlement { const response = await this.fetchFn(url.toString(), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.buildHeaders(), body: JSON.stringify(body), }); @@ -225,7 +273,7 @@ export class ProtocolSettlement { const response = await this.fetchFn(url.toString(), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.buildHeaders(), body: JSON.stringify(body), }); diff --git a/packages/atxp-server/src/serverConfig.ts b/packages/atxp-server/src/serverConfig.ts index 79e03977..041a90b6 100644 --- a/packages/atxp-server/src/serverConfig.ts +++ b/packages/atxp-server/src/serverConfig.ts @@ -6,7 +6,7 @@ type RequiredATXPConfigFields = 'destination'; type RequiredATXPConfig = Pick; type OptionalATXPConfig = Omit; export type ATXPArgs = RequiredATXPConfig & Partial; -type BuildableATXPConfigFields = 'oAuthDb' | 'oAuthClient' | 'paymentServer' | 'logger' | 'minimumPayment'; +type BuildableATXPConfigFields = 'oAuthDb' | 'oAuthClient' | 'paymentServer' | 'logger' | 'minimumPayment' | 'appName'; export const DEFAULT_CONFIG: Required> = { mountPath: '/', diff --git a/packages/atxp-server/src/types.ts b/packages/atxp-server/src/types.ts index 11d21350..fe024002 100644 --- a/packages/atxp-server/src/types.ts +++ b/packages/atxp-server/src/types.ts @@ -74,6 +74,19 @@ export type ATXPConfig = { oAuthClient: OAuthResourceClient; paymentServer: PaymentServer; minimumPayment?: BigNumber; + /** + * Identifier for the calling service (e.g. `"llm"`, `"music-mcp"`). Sent to + * auth as the `X-ATXP-App-Name` request header on /settle/* and /verify/* + * calls so auth can attribute observability events to the originating app. + * + * When omitted, `ProtocolSettlement` falls back to `process.env.APP_NAME`. + * Explicit value takes precedence; set to empty string to disable the env + * fallback for this instance. + * + * Observability metadata only — auth treats this as untrusted. Do not use + * for authorization or billing attribution. + */ + appName?: string; } From 97b32499b18d1ebed658915bf5c996cdeec7cc58 Mon Sep 17 00:00:00 2001 From: bdj Date: Mon, 20 Apr 2026 15:10:56 -0700 Subject: [PATCH 2/3] review: address PR feedback (export options type, uppercase header, express test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: 1. Export ProtocolSettlementOptions from @atxp/server's index.ts — the new type was previously only deep-importable. 2. Switch header name to X-ATXP-APP-NAME to align with the existing X-ATXP-PAYMENT / X-ATXP-TOKEN screaming-case precedent in the SDK. Wire-level it's case-insensitive, but downstream log/metric pipelines key on the literal casing. 3. Rewrite the "resolved once at construction" comment — accurate for long-lived callers (LLM), but @atxp/express instantiates ProtocolSettlement per-request so env IS re-read each time. The old comment overpromised. 4. Add an atxp-express integration test that actually sends an MCP request with a payment credential, stubs global fetch, and asserts the outgoing /settle/* fetch carries the X-ATXP-APP-NAME header when config.appName is set (and omits it when neither config nor env has a value). Closes the loop on the one-line forwarding glue. No runtime behavior change beyond the header casing. --- packages/atxp-express/src/atxpExpress.test.ts | 94 ++++++++++++++++++- packages/atxp-server/src/index.ts | 1 + packages/atxp-server/src/protocol.test.ts | 16 ++-- packages/atxp-server/src/protocol.ts | 15 ++- 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/packages/atxp-express/src/atxpExpress.test.ts b/packages/atxp-express/src/atxpExpress.test.ts index 002d2cf2..227c77c3 100644 --- a/packages/atxp-express/src/atxpExpress.test.ts +++ b/packages/atxp-express/src/atxpExpress.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { atxpExpress } from './atxpExpress.js'; import { MemoryOAuthDb } from '@atxp/common'; import * as TH from '@atxp/server/serverTestHelpers'; @@ -163,4 +163,96 @@ describe('ATXP', () => { scopes_supported: ['read', 'write'], }); }); + + // The forwarding is one line at atxpExpress.ts (new ProtocolSettlement(..., + // { appName: config.appName })), but it's the glue most likely to silently + // break if someone refactors the settlement instantiation — a missing + // passthrough would still compile and pass unit tests. Close the loop by + // asserting the header actually reaches the outgoing fetch. + describe('X-ATXP-APP-NAME header forwarding', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + mockFetch.mockReset(); + // Default: swallow the settle call so the middleware moves on to the + // handler. Tests assert on the headers recorded here. + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ txHash: '0xabc', settledAmount: '100' }), + text: async () => '', + }); + // vi.stubGlobal + unstubAllGlobals is the idiomatic vitest pattern; plain + // reassignment of globalThis.fetch doesn't always propagate through the + // `fetch.bind(globalThis)` used in atxpExpress under all vitest configs. + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const atxpCredential = JSON.stringify({ + sourceAccountId: 'atxp_acct_test123', + sourceAccountToken: 'tok_abc', + }); + + const findSettleCall = () => mockFetch.mock.calls.find( + ([url]) => typeof url === 'string' && url.includes('/settle/'), + ); + + // The middleware only runs the settle path on MCP requests — non-MCP + // requests bail out at parseMcpRequestsNode, which is why the older + // omniChallenge.test.ts tests observe that fetch isn't called. + const sendMcpToolCall = (app: express.Application) => + request(app) + .post('/') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer test-access-token') + .set('X-ATXP-PAYMENT', atxpCredential) + .send(TH.mcpToolRequest()); + + it('forwards config.appName into the X-ATXP-APP-NAME header on /settle/*', async () => { + const router = atxpExpress(TH.config({ + appName: 'music-mcp', + oAuthClient: TH.oAuthClient({ introspectResult: TH.tokenData({ active: true, sub: 'test-user' }) }), + })); + + const app = express(); + app.use(express.json()); + app.use(router); + app.post('/', (_req, res) => res.json({ ok: true })); + + await sendMcpToolCall(app).expect(200); + + const settleCall = findSettleCall(); + expect(settleCall, 'atxpExpress should have called /settle/*').toBeDefined(); + const headers = settleCall![1].headers as Record; + expect(headers['X-ATXP-APP-NAME']).toBe('music-mcp'); + }); + + it('omits the header when config.appName is unset and APP_NAME env is empty', async () => { + const savedAppName = process.env.APP_NAME; + delete process.env.APP_NAME; + try { + const router = atxpExpress(TH.config({ + oAuthClient: TH.oAuthClient({ introspectResult: TH.tokenData({ active: true, sub: 'test-user' }) }), + })); + + const app = express(); + app.use(express.json()); + app.use(router); + app.post('/', (_req, res) => res.json({ ok: true })); + + await sendMcpToolCall(app).expect(200); + + const settleCall = findSettleCall(); + expect(settleCall, 'atxpExpress should have called /settle/*').toBeDefined(); + const headers = settleCall![1].headers as Record; + expect(headers).not.toHaveProperty('X-ATXP-APP-NAME'); + } finally { + if (savedAppName === undefined) delete process.env.APP_NAME; + else process.env.APP_NAME = savedAppName; + } + }); + }); }); \ No newline at end of file diff --git a/packages/atxp-server/src/index.ts b/packages/atxp-server/src/index.ts index 91b65d86..0a7cdd22 100644 --- a/packages/atxp-server/src/index.ts +++ b/packages/atxp-server/src/index.ts @@ -96,6 +96,7 @@ export { detectProtocol, parseCredentialBase64, ProtocolSettlement, + type ProtocolSettlementOptions, } from './protocol.js'; // Omni-challenge builders diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index c8827934..4ed58aa0 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -416,7 +416,7 @@ describe('ProtocolSettlement', () => { }); }); - describe('X-ATXP-App-Name header', () => { + describe('X-ATXP-APP-NAME header', () => { // Auth reads this header and attaches it to settle observability events // so dashboards can slice by calling service. See auth#254. const savedAppName = process.env.APP_NAME; @@ -443,7 +443,7 @@ describe('ProtocolSettlement', () => { await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); - expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('llm'); + expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('llm'); }); it('falls back to process.env.APP_NAME when appName option is omitted', async () => { @@ -457,7 +457,7 @@ describe('ProtocolSettlement', () => { await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); - expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('music-mcp'); + expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('music-mcp'); }); it('explicit appName option overrides process.env.APP_NAME', async () => { @@ -473,7 +473,7 @@ describe('ProtocolSettlement', () => { await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); - expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('from-option'); + expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('from-option'); }); it('explicit empty string disables env fallback (header omitted)', async () => { @@ -491,7 +491,7 @@ describe('ProtocolSettlement', () => { await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); - expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-App-Name'); + expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-APP-NAME'); }); it('omits the header when neither option nor env is set', async () => { @@ -505,7 +505,7 @@ describe('ProtocolSettlement', () => { await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); - expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-App-Name'); + expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-APP-NAME'); }); it('trims whitespace-only values to undefined (header omitted)', async () => { @@ -520,7 +520,7 @@ describe('ProtocolSettlement', () => { await s.settle('x402', credential, { paymentRequirements: { network: 'base' } }); - expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-App-Name'); + expect(headersFromFetch(mockFetch)).not.toHaveProperty('X-ATXP-APP-NAME'); }); it('sets the header on verify() as well as settle()', async () => { @@ -535,7 +535,7 @@ describe('ProtocolSettlement', () => { await s.verify('x402', credential, { paymentRequirements: { network: 'base' } }); - expect(headersFromFetch(mockFetch)['X-ATXP-App-Name']).toBe('llm'); + expect(headersFromFetch(mockFetch)['X-ATXP-APP-NAME']).toBe('llm'); }); }); }); diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 7f50705f..6ad04e9d 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -191,7 +191,7 @@ export interface ProtocolSettlementOptions { appName?: string; } -const APP_NAME_HEADER = 'X-ATXP-App-Name'; +const APP_NAME_HEADER = 'X-ATXP-APP-NAME'; /** * Client for calling auth service verify/settle endpoints. @@ -208,10 +208,17 @@ export class ProtocolSettlement { private readonly destinationAccountId?: string, options?: ProtocolSettlementOptions, ) { - // Resolve appName at construction time so the value doesn't change - // mid-process if someone mutates process.env later. + // Resolve appName once per instance. Long-lived callers (LLM constructs + // once at startup) get a stable value; short-lived callers that rebuild + // the instance — notably @atxp/express, which instantiates per-request + // at atxpExpress.ts:~124 — re-read process.env.APP_NAME each time, which + // is fine because APP_NAME is a deployment-time constant, not runtime + // config. + // // An explicit empty string (options.appName === '') opts out of the env - // fallback — handy for tests that want to assert header-omitted behavior. + // fallback — useful when a single process hosts two services and wants + // to suppress cross-attribution, or in tests asserting header-omitted + // behavior. const explicit = options?.appName; if (explicit !== undefined) { const trimmed = explicit.trim(); From 319655c4774f1e047e540dbfef020e0120480ae1 Mon Sep 17 00:00:00 2001 From: bdj Date: Mon, 20 Apr 2026 15:15:44 -0700 Subject: [PATCH 3/3] review: sync remaining X-ATXP-App-Name references to X-ATXP-APP-NAME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleans up 4 stragglers from the earlier casing switch that weren't caught in the first sweep: - types.ts JSDoc on ATXPConfig.appName - protocol.ts JSDoc on ProtocolSettlementOptions.appName - protocol.ts inline comment on buildHeaders - protocol.test.ts test title Also adds a "Expected format" note to both JSDocs pointing at the format auth's readAppNameHeader enforces (1–64 chars, [a-zA-Z0-9._-]+). The SDK doesn't validate — it trusts callers and sends whatever non-empty trimmed string it has — so a value outside that format produces a missing span attribute on the auth side rather than a failed settle. The docstring flags this so operators debugging absent observability know where to look. No runtime behavior change. --- packages/atxp-server/src/protocol.test.ts | 2 +- packages/atxp-server/src/protocol.ts | 9 +++++++-- packages/atxp-server/src/types.ts | 6 +++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 4ed58aa0..37ef9195 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -431,7 +431,7 @@ describe('ProtocolSettlement', () => { const headersFromFetch = (fetch: ReturnType) => fetch.mock.calls[0][1].headers as Record; - it('sends X-ATXP-App-Name when the explicit appName option is set', async () => { + it('sends X-ATXP-APP-NAME when the explicit appName option is set', async () => { mockFetch.mockResolvedValue(okResponse()); const s = new ProtocolSettlement( 'https://auth.atxp.ai' as any, diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index 6ad04e9d..c7928edf 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -178,7 +178,7 @@ export function parseCredentialBase64(credential: string): Record { const headers: Record = { 'Content-Type': 'application/json' }; diff --git a/packages/atxp-server/src/types.ts b/packages/atxp-server/src/types.ts index fe024002..6369641a 100644 --- a/packages/atxp-server/src/types.ts +++ b/packages/atxp-server/src/types.ts @@ -76,13 +76,17 @@ export type ATXPConfig = { minimumPayment?: BigNumber; /** * Identifier for the calling service (e.g. `"llm"`, `"music-mcp"`). Sent to - * auth as the `X-ATXP-App-Name` request header on /settle/* and /verify/* + * auth as the `X-ATXP-APP-NAME` request header on /settle/* and /verify/* * calls so auth can attribute observability events to the originating app. * * When omitted, `ProtocolSettlement` falls back to `process.env.APP_NAME`. * Explicit value takes precedence; set to empty string to disable the env * fallback for this instance. * + * Expected format (enforced on the auth side — values outside this format + * are silently dropped by the receiver, producing a missing span attribute + * rather than a failed settle): 1–64 characters, `[a-zA-Z0-9._-]+`. + * * Observability metadata only — auth treats this as untrusted. Do not use * for authorization or billing attribution. */