SPEC §9: credential-bearing values are never rendered
Replaces the "peer-derived text in observer-facing errors" section with the rule #788 was actually about: the SDK's own credentials are never rendered. Split out of #793 so that PR's settled §6 material could ship without waiting on this.
Why the old section could not converge
It tried to hold a general claim — no peer-derived text in any observer-facing rendering — across six languages. Every formulation of its boundary enumerated destinations (call sites, constructors, egresses, a closed vocabulary with carve-outs), and destinations are an open set: each was defeated by the next sweep, across ten bot-review rounds. The deeper problem: the claim was defending against nothing. Server-chosen text — a close reason, an error body, a decoder's quotation — reaching a Basecamp customer's own log is not a leak. What must not echo is the SDK's own secrets.
The rule now
Secrets are a closed set the SDK creates or requests (the table in §9 is the enumeration; the prose deliberately never restates its size — it drifted twice when rows were added):
| Secret | Travels in | Renders it if unguarded |
|---|---|---|
| Bearer token | Authorization header | header logging — already redacted |
| Stream ticket (§23) | mint URL query | a transport's rendering of the dial URL |
| Signed download URL (§14 hop 2) | the URL itself | a transport's rendering of the hop-2 URL, or a pre-dial validation error |
| OAuth token + device-authorization responses (§16) | response bodies | a JSON parser quoting — or retaining (JSONDecodeError.doc) — the body |
| OAuth request bodies (§16) | form bodies (client_secret, code, code_verifier, device_code, refresh token) | request-body logging, which nothing in the SDK does; that is the invariant to keep |
The trust model, stated once: the API peer is Basecamp (§8/§23 refuse cross-origin before any request); the storage host and the OAuth endpoints are the two other hosts, and both are exactly the credential-bearing exchanges, so their error bodies are never rendered — a hop-2 failure is status-only (all six SDKs already do this), an OAuth failure carries RFC 6749's error/error_description and nothing else.
The rule binds at the construction sites where those values can enter an error: project before any library renders the whole (origin only; unparsable where no complete origin exists), and keep the raw value out of the cause chain — with the two runtime footguns stated: CPython attaches __context__ at raise time (from None silences the traceback but leaves the body-retaining exception in the slot, so the remedies are raise-outside-the-handler or clear-after-raise), and MRI attaches the implicit cause past any cause: keyword the class stores (raise-site cause: nil; Ruby's OAuth exchange already does, and is the pattern). Hop 1's request hooks get the rewritten download URL projected to origin+path, since downloadURL accepts any absolute URL and the rewrite preserves a signed input's query. Handing a credential to its owner (on_refresh, an auth strategy returning its token) is not rendering.
No rule on non-credential text at all: close reasons, decoder messages, reason phrases, rejected Link URLs, and retained non-secret fields are Basecamp's own text, bounded by the existing cap.
Kept on its own merits
- §6 step 5's empty/unparseable-body fallback is the fixed code-bearing phrase — "HTTP status text" was three different things (wire reason phrase, absent in HTTP/2; platform tables, empty for 599 in Go and localized on Apple).
- §6 step 6 caps
hintas well asmessage. - §14 hop 2 fires no request hooks and renders failures status-only (now stated; all six SDKs already comply).
unparsableas the fallback for the origin-only renderings §23 already required.- §12's
on_paginateis Ruby and Python, not Ruby only.
SDK follow-ups (#788)
- Hop-2/transport network errors in Python, TypeScript, Ruby, Kotlin interpolate the transport's rendering and chain the raw cause; Go's
ErrNetworkputscause.Error()inHintand returns it fromUnwrap; Swift owes only the chain. - Pre-dial signed-URL validation errors render the URL in Ruby (
client.rb) and Swift (HTTPClient.swift). - Python's
auth.pyrefresh chainsJSONDecodeErrorwithfrom e;oauth/exchange.py/device.pyusefrom Noneand owe the raising-boundary form. - Hop-1 hook URL projection (TypeScript passes
rewrittenURLwhole today; others to check). - Step 5's fixed phrase in TypeScript, Kotlin, Go's
checkResponse, Swift. - Each §23 connector, as written, owes projection at its dial-failure site.
Open question left for the author
One review thread is deliberately open: whether the OAuth endpoints' error_description should be rendered at all. The section renders it (truncated) and says why — it is the caller's only OAuth diagnostic, and the endpoint that could echo a submitted secret into it is the endpoint the SDK handed that secret to. Codex argues for status-only. Judgment call.
Refs #788 — closable on this basis once the rows above land.
Summary by cubic
Rewrites §9 to protect the SDK's own credentials instead of policing all peer-derived text. The old rule guarded the wrong thing — Basecamp is the SDK's only API peer, so server-chosen error text reaching a customer's log is not a leak — and every boundary it tried (call sites, constructions, egresses) was an open set. The new rule names the closed set of values that carry a credential the SDK holds or requested (bearer token, stream ticket, signed download URL, OAuth and device-authorization responses, and the OAuth form fields client_secret, code, code_verifier, device_code, refresh_token) and binds at the construction sites where each can enter an error, requiring origin-only projection plus cause-chain severing there. The set's size lives only in the table — a prose count drifted twice when rows were added.
- The trust model names three non-API hosts — hop 2's storage host, the OAuth token endpoint, and the device-authorization endpoint — all credential-bearing exchanges. Hop-2 failures render the status alone; OAuth and device failures render RFC 6749's
error/error_description, deliberately, since that is the caller's only OAuth diagnostic. Within the API peer, server-chosen text is bounded by the cap and free to be diagnostic. - Pre-dial validation failures on a signed
Locationare the same value and render the same way; Ruby'sclient.rband Swift'sHTTPClient.swiftrender the URL before any transport, and Python'sauth.pyrefresh chains aJSONDecodeErrorwhose.docretains the token body. - Sever the cause chain at construction:
from Noneis not sufficient in Python —__context__still holds the body-retainingJSONDecodeError— so raise where no exception is active or clear__context__after; MRI sets its cause at raise time past any stored keyword. - Handing a credential to its owner —
on_refresh(access_token, ...)or an auth strategy returning the token it holds — is not rendering and sits outside the rule. - The inventory was settled by sweep after three rounds each surfaced a missing row: held credentials — the bearer inside the auth strategy, the configured
client_secret, the §15 webhook signing secret — never transit a failure path and owe only that no rendering is built from configuration, which none is. A caller who serializes their own configured object is publishing their own secret; no library rule reaches that act. - §6 step 5 renders the fixed phrase
Request failed (HTTP 500), never the wire reason phrase, and step 6 capshintalongsidemessage. - Hop 2 fires no request hooks and renders the signed URL as origin only on failure; hop 1's request hooks receive the rewritten URL as origin and path only — no query, no fragment — since
downloadURLpreserves a caller-passed signed URL's signature through the rewrite. - §23 dial failures render the mint URL as origin only, with
unparsablewhere no origin forms; poll and resume URLs are not credentials, sogap(resume_url)andcatch_up_started(cursor)carry them whole. - Ruby's
client.rbexchange is already conformant and is the pattern to copy; the remaining hop-2 and pre-dial sites that still render or chain the credential are tracked in #788, and §12'son_paginateis Ruby and Python.