Project the Kotlin transport error before it becomes the network message
The Kotlin side of #174 (Rust), #175 (Go) and #177 (Swift).
The leak. Ktor's timeout exceptions render the request URL in their message — HttpRequestTimeoutException: Request timeout has expired [url=…, request_timeout=… ms], and the engine-built ConnectTimeoutException and SocketTimeoutException likewise. FizzyHttpClient built its Network error as "Network error: ${e.message}" with the raw exception chained, and handed the raw exception to RequestResult.error, so a timeout on a request whose query is signed — an Active Storage direct-upload or blob URL, where the query is the credential — put the signature into the SDK error's message, toString(), stackTraceToString(), cause chain and the request-end hook: everywhere the error gets logged.
The fix. One helper, redactTransportError in TransportErrors.kt, with displayUrl and displayOrigin beside it. A Ktor timeout exception is rebuilt from parts this SDK chooses: the same class, so a caller classifies it as before, and the URL the SDK issued (a redirect's target is the transport's business and is not rendered) projected to origin and path when it sits on the configured base URL without userinfo, and to the origin alone anywhere else, because a storage service can sign the path as readily as the query — following basecamp-sdk SPEC §9's "projection, not redaction" and the trusted-origin rule of #175, since this repo has no spec of its own to say otherwise. Every other exception is the transport's own diagnostic — a JVM socket failure names host and port at most — and passes through unchanged, cause chain included. The budget rendered beside the projected URL is the one the SDK itself installed (configureClient); for a caller-supplied HttpClient none is claimed, since the number in the transport's own message is text it wrote and is not read. Sites: both transport catches in FizzyHttpClient (requestWithRetry, requestBinaryWithRetry), projected once before the request-end hook so hooks and the caller see the same shape.
The test. TransportErrorProjectionJvmTest drives the shipped CIO engine's own HttpTimeout against a listener that accepts and never answers, with ?signature=SECRETVALUE on the request, and asserts SECRETVALUE is absent from message, toString(), stackTraceToString(), hint, every link of the cause chain and RequestResult.error; that the cause is still an HttpRequestTimeoutException; and that the message keeps http://127.0.0.1:<port>/blob. TransportErrorProjectionTest covers the same site, and the binary request path, through MockEngine for all three Ktor timeout shapes, a Connection refused passing through by class and message, and the trusted-origin, foreign-origin, userinfo and unparsable projections. On main the engine test fails with:
expected: <Network error: Request timeout has expired [url=http://127.0.0.1:61271/blob, request_timeout=30000 ms]> but was: <Network error: Request timeout has expired [url=http://127.0.0.1:61271/blob?signature=SECRETVALUE, request_timeout=300 ms]>
Gates run locally: make kt-check, ./gradlew :conformance:run.
Review rounds. Copilot found the budget rendered for a caller-supplied HttpClient (now omitted), the connect/socket projections dropping theirs (now carried), the untested binary path (now tested) and the JVM test's unclosed client (now closed). Declined: projecting RequestInfo.url for API requests (the hooks see API requests whole by design; the credential is in the header) and a conformance fixture (the fixtures cannot force a transport failure).
Watching the review loop on this one through to convergence.