Skip to content

Retry the authenticated download hop under a declared set, in every SDK - #563

Merged
jeremy merged 16 commits into
mainfrom
feat/download-hop1-retry
Aug 1, 2026
Merged

Retry the authenticated download hop under a declared set, in every SDK#563
jeremy merged 16 commits into
mainfrom
feat/download-hop1-retry

Conversation

@jeremy

@jeremy jeremy commented Aug 1, 2026

Copy link
Copy Markdown
Member

SPEC §14 prescribed a two-hop download that threw on the first error, while conformance/tests/downloads.json asserted retry at hop 1 — so four of the five runners carried a skip for the same two fixtures and the spec was the thing that was wrong. This makes §14 honest and brings every SDK to it.

The policy (§14, new "Hop-1 Retry [conformance]" subsection):

  • Hop 1 retries network errors plus {429, 502, 503, 504} — never 500. 500 stays out deliberately: the download hop keeps the main GET loop's declared-set discipline rather than the error taxonomy's broader "all 5xx retryable" flag.
  • Exponential backoff from a 1-second base, Retry-After honored on 429.
  • Every hop-1 attempt is authenticated — each attempt re-runs the auth strategy so a rotated token is picked up.
  • Hop 2 is exempt: never retried, never authenticated. The signed URL is single-purpose and credentials must not leak to the storage host.
  • Disabling retry yields exactly one hop-1 attempt everywhere.

Attempt budgets, per SDK:

SDK Budget
Go MaxRetries as total attempts (hand-written client rejects < 1)
Python max_retries as total attempts, floored at one
Ruby max_retries as total attempts, floored at one for downloads; the ungoverned GET path's zero-attempt behavior stays tracked in #532
Kotlin maxRetries as total attempts, floored at one, gated on enableRetry
TypeScript Fixed three attempts when enableRetry, one when false. No public numeric knob.
Swift Fixed three attempts when enableRetry, one when false. No public numeric knob.

DownloadURL is deliberately absent from behavior-model.json, so every SDK passes this policy to its retry primitive directly rather than looking it up by operation.

Per-SDK

TypeScript — lifted the retry loop out of createRetryingFetch into retry.ts as executeWithRetry(makeAttempt, config, emit, signal) with four seams: attempt begin/finalize/retrying emission, caller-owned attempt preparation (request rebuild + auth refresh, terminal-marked so auth faults keep their identity past retry classification), per-attempt fetch, and the signal-aware backoff sleep. The client's middleware path delegates to it unchanged; download.ts adopts it with the fixed policy.

Pythonget_no_retry becomes get_download, running through _request_with_retry with an explicit retry_on. Sync and async transports change identically.

Rubyget_no_retry becomes get_download, running through request_with_retry with an explicit retry_on; the declared set is authoritative in both directions (the taxonomy neither widens nor vetoes it).

Kotlin — hop 1 now runs in downloadHop1, a directive-shaped loop mirroring BasecampHttpClient.requestWithRetry (#517): catch clauses only classify the attempt's outcome, and the retry side effects run outside any catch, so a CancellationException from the backoff propagates raw and no phantom request events fire for an attempt that already ended. A throwing auth strategy surfaces raw through an internal tag, spending no retry budget.

SwiftperformDownloadRequest gains the same #517 directive loop. Pinned by native XCTests only; the Swift conformance runner lands in #558.

Go — already conformed; this is a pin, no behavior change. Its tests covered only three of five edges (503, 429-with-Retry-After, network errors), so 502/504 rode along untested, the 500 carve-out was only implied by a 404 case, and nothing asserted a retried attempt still carries Authorization.

Red proofs

Each SDK's new tests run against that SDK's pristine (pre-change) implementation, swapped in with git show origin/main:<path>.

TypeScriptnpx vitest run tests/download.test.ts against pristine download.ts:

× retries hop 1 on 429, then follows the redirect
× retries hop 1 on 502, then follows the redirect
× retries hop 1 on 503, then follows the redirect
× retries hop 1 on 504, then follows the redirect
× retries hop 1 on a network error
× exhausts the three-attempt budget and surfaces the final error
× sends Authorization on every hop-1 attempt and never on hop 2
× fires balanced start/end hooks and one onRetry per backoff
× honors Retry-After on 429 at the client level
Tests  9 failed | 32 passed (41)

Pythonuv run pytest tests/test_download.py against pristine download.py + _http.py + _async_http.py:

FAILED tests/test_download.py::TestHop1Retry::test_retries_declared_status_then_succeeds[429]
FAILED tests/test_download.py::TestHop1Retry::test_retries_declared_status_then_succeeds[502]
FAILED tests/test_download.py::TestHop1Retry::test_retries_declared_status_then_succeeds[503]
FAILED tests/test_download.py::TestHop1Retry::test_retries_declared_status_then_succeeds[504]
FAILED tests/test_download.py::TestHop1Retry::test_retries_network_error_then_succeeds
FAILED tests/test_download.py::TestHop1Retry::test_exhausts_cap_then_surfaces_error
FAILED tests/test_download.py::TestHop1Retry::test_auth_on_every_hop1_attempt_never_on_hop2
FAILED tests/test_download.py::TestHop1Retry::test_balanced_hooks_across_retries
FAILED tests/test_download.py::TestHop1Retry::test_honors_retry_after_on_429
FAILED tests/test_download.py::TestHop1RetryAsync::test_retries_declared_status_then_succeeds[429]
FAILED tests/test_download.py::TestHop1RetryAsync::test_retries_declared_status_then_succeeds[502]
FAILED tests/test_download.py::TestHop1RetryAsync::test_retries_declared_status_then_succeeds[503]
FAILED tests/test_download.py::TestHop1RetryAsync::test_retries_declared_status_then_succeeds[504]
FAILED tests/test_download.py::TestHop1RetryAsync::test_retries_network_error_then_succeeds
14 failed, 15 passed in 0.59s

Rubybundle exec ruby -Itest -Ilib test/basecamp/download_test.rb against pristine http.rb + client.rb:

Basecamp::ApiError: Gateway error (502)
Basecamp::ApiError: Gateway error (503)
Basecamp::ApiError: Gateway error (504)
Basecamp::RateLimitError: Rate limit exceeded
Basecamp::NetworkError: Connection failed
NoMethodError: undefined method 'get_download' for #<Basecamp::Http>
41 runs, 54 assertions, 1 failures, 8 errors, 0 skips

Kotlin./gradlew :basecamp-sdk:jvmTest --tests "com.basecamp.sdk.DownloadTest" --rerun-tasks against pristine Download.kt:

DownloadTest[jvm] > downloadURL_exhaustsCapThenSurfacesError()[jvm] FAILED
    org.opentest4j.AssertionFailedError at DownloadTest.kt:572

DownloadTest[jvm] > downloadURL_authOnEveryHop1AttemptNeverOnHop2()[jvm] FAILED
    com.basecamp.sdk.BasecampException$Api at DownloadTest.kt:642

DownloadTest[jvm] > downloadURL_honorsRetryAfterOn429()[jvm] FAILED
    com.basecamp.sdk.BasecampException$RateLimit at DownloadTest.kt:735

DownloadTest[jvm] > downloadURL_retriesNetworkErrorThenSucceeds()[jvm] FAILED
    com.basecamp.sdk.BasecampException$Network at DownloadTest.kt:546

DownloadTest[jvm] > downloadURL_retriesDeclaredStatusesThenFollowsRedirect()[jvm] FAILED
    com.basecamp.sdk.BasecampException$RateLimit at DownloadTest.kt:479

DownloadTest[jvm] > downloadURL_balancedHooksAcrossRetries()[jvm] FAILED
    com.basecamp.sdk.BasecampException$Api at DownloadTest.kt:685

36 tests completed, 6 failed

Kotlin conformance, same pristine Download.kt with KOTLIN_SKIPS already emptied:

  FAIL: DownloadURL retries on 503 at the auth'd first hop
        Expected 4 requests, got 1
  FAIL: DownloadURL retries hop 1 on a network error
        Expected 3 requests, got 1
  PASS: DownloadURL does not retry hop 1 on 500
  FAIL: DownloadURL honors Retry-After on 429 at the auth'd first hop
        Expected 3 requests, got 1
Passed: 129, Failed: 3, Skipped: 1, Total: 133

Swiftswift test --filter DownloadTests against pristine HTTPClient.swift + Download.swift:

testDownloadURL_authOnEveryHop1AttemptNeverOnHop2 : failed: caught error: "api(message: "service unavailable", httpStatus: Optional(503))"
testDownloadURL_balancedHooksAcrossRetries : failed: caught error: "api(message: "service unavailable", httpStatus: Optional(503))"
testDownloadURL_cancellationDuringBackoffPropagatesRaw : failed - Expected CancellationError, got api(message: "service unavailable", httpStatus: Optional(503))
testDownloadURL_cancellationDuringBackoffPropagatesRaw : XCTAssertEqual failed: ("[]") is not equal to ("[2]") - A cancelled backoff must not emit a second onRetry
testDownloadURL_exhaustsThreeAttemptsThenSurfaces : XCTAssertEqual failed: ("1") is not equal to ("3")
testDownloadURL_honorsRetryAfterOn429 : failed: caught error: "rateLimit(message: "client error", retryAfterSeconds: Optional(2))"
testDownloadURL_retriesDeclaredStatusesThenFollowsRedirect : failed: caught error: "rateLimit(message: "client error", retryAfterSeconds: nil)"
testDownloadURL_retriesNetworkErrorThenSucceeds : failed: caught error: "network(message: "Network error", cause: Optional(TestError()))"

Executed 33 tests, with 8 failures (5 unexpected) in 0.544 seconds

testDownloadURL_neverRetries500 and testDownloadURL_enableRetryFalseSendsExactlyOneAttempt pass against pristine by construction — pristine never retries. They pin the ceiling, not the floor.

Go — the implementation is unchanged, so the pins are proved by mutating it instead. Three mutations, each restored:

Narrow the switch back to {429, 503}:

--- FAIL: TestDownloadURL_AuthHopDeclaredRetrySet
    --- PASS: .../429_is_retried
    --- FAIL: .../502_is_retried
    --- PASS: .../503_is_retried
    --- FAIL: .../504_is_retried
    --- PASS: .../500_is_outside_the_set

Widen it to the all-5xx taxonomy shape (429 || >= 500):

--- FAIL: TestDownloadURL_AuthHopDeclaredRetrySet
    --- PASS: .../429_is_retried
    --- PASS: .../502_is_retried
    --- PASS: .../503_is_retried
    --- PASS: .../504_is_retried
    --- FAIL: .../500_is_outside_the_set

Authenticate only on attempt 1:

    download_test.go:1005: hop-1 attempt 2: expected "Bearer test-token", got ""
--- FAIL: TestDownloadURL_AuthHopAuthOnEveryAttemptNeverOnHop2

Hop-2 no-auth verification

The credential boundary is asserted at three levels, and every level checks both directions — present on hop 1, absent on hop 2:

  1. Fixturedownloads.json's 503 case now asserts headerPresent: Authorization at index 0, 1, and 2 (previously index 0 only, so a retry that dropped the token would have passed) and headerAbsent: Authorization at index -1. Both new cases carry the same pair.
  2. Native, every SDK — a dedicated test records the Authorization header on each hop-1 attempt and on the signed hop across a 503 → 302 → 200 flow, asserting ["Bearer test-token", "Bearer test-token"] then absent. Go's is new in this PR; the others ship with their SDK commit. Proved load-bearing in Go by authenticating only on attempt 1 (above).
  3. Implementation — hop 2 is a separate bare call in all six SDKs (fetchSignedDownload, a fresh unauthenticated client, a bare Net::HTTP GET, a Ktor request with no auth block, raw fetch), outside the retry loop entirely.

Fixture and roster deltas

conformance/tests/downloads.json:

  • 503 case — description corrected (it claimed "SDK-wide GET retry semantics: retry on 5xx", which was never true and contradicts the 500 carve-out); Authorization now asserted on every hop-1 attempt, not just the first.
  • New: "DownloadURL retries hop 1 on a network error" — {networkError: true} → 302 → 200, 3 requests, delayBetweenRequests ≥ 1000, auth on both hop-1 attempts and absent on hop 2.
  • New: "DownloadURL does not retry hop 1 on 500" — one request, status 500.

Both new cases are registered in SPEC Appendix D (§14, §7). The §19 category table needs no new row — they live in the already-listed downloads.json.

SPEC §19 zero-skip roster — all eight download-retry lines deleted (Python ×2, Ruby ×2, TypeScript ×2, Kotlin ×2), matching the runners:

Runner Before After
Python SKIPS 2 0 — none
Ruby RUBY_SKIPS 13 11 (all waiver 2B.3 GET-only)
TypeScript TS_SDK_SKIPS 3 1 (waiver 1B.6, 53-bit Number)
Kotlin KOTLIN_SKIPS 2 0 — empty; one tag-based link-header branch remains (architectural)
Go goSDKSkips 2 2 (unchanged, architectural)

No unwaivered skips remain in any runner.

Guard repoint

scripts/check-retry-metadata-parity.py's token smoke watched client.ts and http.rb for expressions the refactors relocated — TypeScript's loop moved to retry.ts, and Ruby's status gate became retry_eligible?, where the declared set arrives from either the operation's metadata or an explicit set. The guard now watches where the behavior lives: a client.ts row for resolving the tuple and delegating, a new retry.ts row for the loop, and a Ruby row requiring the metadata read and the membership test as separate tokens. Strength is unchanged — stubbing config.retryOn.includes in retry.ts or declared.include? in http.rb still reddens the check.

Verification

make check green end to end.

Suite Result
Go ok all packages
TypeScript 76 files, 1051 tests passed
Python 759 passed
Ruby 1009 runs, 2280 assertions, 0 failures, 0 errors
Kotlin BUILD SUCCESSFUL
Swift 275 tests, 0 failures
Conformance runner Result
Go 131 passed, 0 failed, 2 skipped
Kotlin 132 passed, 0 failed, 1 skipped
TypeScript 153 passed, 2 skipped, 0 failed
Ruby 122 passed, 0 failed, 11 skipped
Python 133 passed, 0 failed, 0 skipped

Follow-up

Draft PR #558 (Swift conformance runner) carries two temporary skips for exactly these fixtures — "DownloadURL retries on 503 at the auth'd first hop" and "DownloadURL honors Retry-After on 429 at the auth'd first hop" — which it flips to live once this merges. Not touched here; that flip belongs to #558's own landing checklist.


Review round

14 threads (Codex ×1, cubic ×13; Copilot errored out again). Four changed the code, four became issues or were declined on verified evidence, the rest are recorded below with reasoning.

Fixed

  • Python + Ruby hop 1 sent Accept: application/json (4710daa7c). Real, and a §14 violation — line 988 has always said hop 1 sets Authorization and User-Agent only. Not a regression, though: main's get_no_retry went through the same header builder, so the header rode on the single attempt too. Both transports now thread an accept seam from the retry entry point to the header builder, defaulting to the JSON Accept so every other caller is untouched; the download hop passes none, and the 401-refresh replay carries it through. What remains on the wire is the HTTP library's own default (httpx */*, Faraday nothing) — the tests pin hop 1 against the bare hop-2 client rather than pretending the SDK controls it. Red against the un-fixed transports in both languages.
  • Kotlin's DownloadAuthFailure duplicated AuthPhaseFailure (b1fd56787). Same shape, same job. AuthPhaseFailure is now internal and shared.
  • Swift's cancellation test polled unbounded (b1fd56787). while transport.requests.isEmpty now gives up after five seconds with a named failure instead of hanging the suite.

Filed, not fixed here

  • Kotlin's Retry-After parser ignores the HTTP-date form; TypeScript's retry paths bypass the compliant one #564Retry-After ignores the HTTP-date form and non-positive deltas. SPEC §6:421 specifies integer-> 0, then HTTP-date, then fall through. No SDK does step 2; TypeScript also skips the > 0 guard, so Retry-After: 0 gives a 0 ms delay. All pre-existing — the TypeScript block is a character-for-character lift of client.ts:1114-1121 on main. Fixing it means six parsers plus a fixture for the date form, and a decision on whether §6 step 2 survives.
  • A 401 token-refresh replay is not counted against the attempt cap #565 — a 401 refresh replay is not counted against the attempt cap. True, and unchanged by this PR: the replay lives in _single_request, which main's get_no_retry already called. Definitional rather than mechanical — is re-authentication a retry? — so it wants a decision before a patch.

Declined, with evidence

  • "Release the discarded Ktor response before retrying" (Codex P2 + cubic, 2 threads). The premise does not hold on Ktor 3.5.1: HttpClient's constructor installs SaveBody unconditionally (verified in javap output), and the plugin's own deprecation text states "Request bodies are now saved in memory by default for all non-streaming responses". downloadHop1 uses client.request(url) — non-streaming — so the connection is released before it returns and response.cancel() would be a no-op. The shipping BasecampHttpClient.requestWithRetry has the identical shape, so if the premise held it would be a live bug on main.
  • "Retry a BasecampError.network from the transport" (Swift). Deliberate, and identical to performRequest's documented classification: the response-type guard raises it for a programming error, and a transport that pre-wraps failures as BasecampError has opted into terminal handling. Diverging one loop from the other on a shared rule is worse than the gap.
  • A Swift base-delay test seam. The ~13s buys a red proof that swaps in origin/main's files untouched. A seam has to exist in the source for the tests to compile, which makes "pristine" no longer pristine.
  • delays[:-1] in the Python conformance runner. The change was an alignment, not a regression — Go and Ruby already gate on the first gap, and any(...) failed on a correct implementation because the download flow's final gap is the deliberately un-delayed redirect hop. [:-1] is a better rule, but applying it to one runner of five is how a regression gets caught in one language and tolerated in the rest.
  • Ruby final-attempt "no sleep, no on_retry" pin, and hoisting DOWNLOAD_RETRY_ON out of the two Python transports. Both fair; both wrong to do in one SDK when five carry the same shape.

make check re-run green end to end after the round (REAL_EXIT=0): Go ok, TypeScript 1051, Python 761, Ruby 1010 runs / 2283 assertions, Kotlin BUILD SUCCESSFUL, Swift 275 — conformance 131/132/153/122/133, zero failures.

jeremy added 8 commits July 31, 2026 19:02
The two-hop download algorithm prescribed throw-on-error with no retry,
while the conformance fixtures asserted retry the four runners had to
skip. Make §14 honest: hop 1 retries on network errors plus
{429, 502, 503, 504} — never 500 — with the per-SDK attempt budgets
(public total-attempt caps floored at one for Python/Ruby/Kotlin, a
fixed three-attempt policy for TypeScript/Swift), and hop 2 stays
no-retry, no-auth.

Fixture changes: the 503 case now asserts Authorization on EVERY hop-1
attempt (not just request 0) and drops its wrong all-5xx description;
new cases pin network-error retry and 500 non-retry. Appendix D gains
the two new rows. The eight download-retry roster lines (Python,
Ruby, TypeScript, Kotlin × 2) leave §19 — the per-SDK commits remove
the corresponding runner skips.
Lift the retry loop out of createRetryingFetch into retry.ts as
executeWithRetry(makeAttempt, config, emit) with four seams — attempt
begin/finalize/retrying emission, caller-owned attempt preparation
(request rebuild + auth refresh, terminal-marked so auth faults keep
their identity past retry classification), per-attempt fetch, and the
signal-aware backoff sleep. The client's middleware path delegates to
it unchanged.

downloadURL's hop 1 adopts the primitive with the fixed SPEC §14
policy passed directly (three attempts when enableRetry, retryOn
{429, 502, 503, 504}, never 500; DownloadURL is deliberately absent
from behavior-model.json). Hop 2 stays raw fetch: no retry, no auth —
pinned by a native test asserting Authorization on every hop-1
attempt and never on hop 2.

Native tables pin the complete retry set, 500 non-retry, the
enableRetry=false single attempt, timeout-abort terminality, balanced
start/end/onRetry hooks across retries, and client-level Retry-After.
The runner drops its two download skips and runs downloads.json
retry-enabled.
… set

get_no_retry becomes get_download: the authenticated hop-1 GET now runs
through _request_with_retry with an explicit retry_on set — SPEC §14's
{429, 502, 503, 504}, never 500 — passed directly because DownloadURL
is deliberately absent from behavior-model.json. The declared set is
authoritative in both directions, carving downloads out of the
ungoverned GET taxonomy (which retries all 5xx). Network errors keep
§7's classification; the public max_retries total-attempt cap applies,
floored at one. Hop 2 stays a fresh unauthenticated client: no retry,
no auth. Sync and async transports change identically.

Native tables (sync + async) pin the complete declared set, 500
non-retry, network-error retry, cap exhaustion, the max_retries: 0
single-attempt floor, Authorization on every hop-1 attempt and never
on hop 2, balanced start/end/on_retry hooks, Retry-After on 429, and
async cancellation as terminal.

The runner drops its two download skips (zero skips remain) and aligns
delayBetweenRequests to the first-gap semantic the Go, TypeScript, and
Kotlin runners already use — the download flow's final gap is the
redirect hop to the signed URL, which is deliberately un-delayed.
get_no_retry becomes get_download: the authenticated hop-1 GET now runs
through request_with_retry with an explicit retry_on set — SPEC §14's
{429, 502, 503, 504}, never 500 — passed directly because DownloadURL
has no behavior-model entry. The declared set is authoritative in both
directions, carving downloads out of the ungoverned GET taxonomy (which
retries all retryable 5xx). Status-less network errors keep the
taxonomy's judgment.

The download attempt budget is the public max_retries total-attempt
cap floored at one — FOR DOWNLOADS ONLY, by sharing the governed
caller_cap shape. The ungoverned general path keeps its unfloored cap;
its max_retries: 0 zero-attempt behavior is tracked separately (#532).
Hop 2 stays a bare Net::HTTP GET: no retry, no auth.

Native tests pin the complete declared set status by status, 500
non-retry, network-error retry, cap exhaustion, the max_retries: 0
single-attempt floor, Authorization on every hop-1 attempt and never
on hop 2, balanced start/end/on_retry hooks, and Retry-After on 429.

The runner drops its two unwaivered download skips (11 waivered 2B.3
GET-only skips remain) and aligns delayBetweenRequests to the
first-gap semantic the Go, TypeScript, and Kotlin runners already
use — the download flow's final gap is the redirect hop to the signed
URL, which is deliberately un-delayed.
… set

downloadURL built a one-shot Ktor client with followRedirects off and
fired a single unguarded request through it, so the authenticated hop
surfaced every 429/502/503/504 straight to the caller and the two
download-retry conformance fixtures had to be skipped.

Hop 1 now runs in downloadHop1, a directive-shaped loop mirroring
BasecampHttpClient.requestWithRetry (#517): the catch clauses only
classify the attempt's outcome, and the retry side effects — onRetry,
the backoff sleep, the next attempt — run outside any catch, so a
CancellationException from the sleep propagates raw and no phantom
request events fire for an attempt that already ended. The declared set
is {429, 502, 503, 504} plus network errors; 500 stays out, matching the
main GET loop's declared-set discipline rather than the error taxonomy's
broader all-5xx flag. The budget is the public maxRetries total-attempt
cap coerced to at least one, so an accepted maxRetries = 0 still sends
exactly one attempt, and enableRetry = false collapses it to one.

Every attempt re-runs the auth strategy so a rotated token is picked up.
A throwing strategy is a configuration fault, not a transport fault: it
surfaces raw through the DownloadAuthFailure tag, spends no retry
budget, and matches BasecampHttpClient's auth-phase classification. The
signed second hop is untouched — no retry, no auth.

KOTLIN_SKIPS is now empty: the two download-retry fixtures run, and the
remaining architectural skip is the tag-based link-header branch.
performDownloadRequest fired exactly one authenticated request, so
downloadURL surfaced every 429/502/503/504 from the API hop straight to
the caller — the one leg of the two-hop flow where a transient gateway
blip is worth another attempt.

It now runs the #517 directive loop: the do/catch classifies the
attempt's outcome into done/retry/fail and the loop tail acts on it, so
onRetry, the backoff sleep, and re-authentication never run inside a
catch clause and a CancellationError from the sleep propagates raw. The
policy is fixed and passed directly rather than looked up by operation —
DownloadURL is deliberately absent from behavior-model.json, so there is
no per-operation RetryConfig to resolve and no public numeric knob. Three
attempts when enableRetry is true, exactly one when false; the declared
set is {429, 502, 503, 504} plus network errors, never 500; exponential
backoff from the 1-second base, honoring Retry-After on 429.

Every attempt re-authenticates so a rotated token is picked up. The
signed second hop is untouched: fetchSignedDownload still fires once,
bare.

Native tests pin the complete retry set status by status, the 500
non-retry, the fixed three-attempt exhaustion, the enableRetry = false
single attempt, Authorization on every hop-1 attempt and never on hop 2,
balanced start/end/onRetry hooks across retries, Retry-After, and
cancellation during backoff. The Swift conformance runner lands
separately (#558).
…honors

Go's fetchAPIDownload has always retried the declared hop-1 set, but the
tests only covered three of its five edges: 503, 429-with-Retry-After,
and network errors. 502 and 504 rode along untested, the 500 carve-out
was only implied by a 404 case, and nothing asserted that a retried
attempt still carries Authorization — the property that makes hop-1
retry safe to add elsewhere.

Two pins, no behavior change. A table walks the complete set status by
status, including the 500 row that must stay at one attempt and surface
a non-retryable error. A second test records the Authorization header on
every authenticated-hop attempt and on the signed hop, asserting the
credential boundary holds across a retry.

Both fail against mutated implementations: narrowing the switch to
{429, 503} reddens the 502 and 504 rows, widening it to all 5xx reddens
the 500 row, and authenticating only on attempt 1 reddens the header
test with an empty Authorization on attempt 2.
Routing download hop 1 through the shared retry primitives moved two of
the guard's watch points. TypeScript lifted the loop out of client.ts
into retry.ts, so client.ts no longer spells retryOn.includes or the
backoff math; Ruby's status gate became retry_eligible?, where the
declared set arrives either from the operation's metadata or from an
explicit set like the download flow's, so fetch("retryOn") and the
membership test are no longer one expression.

The guard now watches where the behavior actually lives: a client.ts row
for resolving the per-operation tuple and handing it to executeWithRetry,
a new retry.ts row for the loop that consumes it, and a Ruby row that
requires the metadata read and the membership test as separate tokens.
Strength is unchanged — stubbing config.retryOn.includes in retry.ts or
declared.include? in http.rb still reddens the check.
Copilot AI review requested due to automatic review settings August 1, 2026 03:32
@jeremy jeremy added enhancement New feature or request typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK go kotlin swift conformance Conformance test suite python Pull requests that update the Python SDK labels Aug 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c1e17fdfd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 issues found across 24 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="python/src/basecamp/_async_http.py">

<violation number="1" location="python/src/basecamp/_async_http.py:131">
P2: Retries-disabled downloads can still make a second hop-1 request after a 401 token refresh. Keep the 401 refresh retry from bypassing the download attempt cap so `max_retries=0` yields exactly one hop-1 request.</violation>
</file>

<file name="ruby/lib/basecamp/http.rb">

<violation number="1" location="ruby/lib/basecamp/http.rb:179">
P2: Download hop-1 retries now send `Accept: application/json` because they use `single_request`/`request_headers`. Scope headers by request type so this flow sends only its required auth and User-Agent headers.</violation>
</file>

<file name="python/src/basecamp/_http.py">

<violation number="1" location="python/src/basecamp/_http.py:131">
P2: Download hop-1 still sends `Accept: application/json` because this call uses the generic request path. Scope headers for this flow to Authorization and User-Agent so binary download requests meet the required per-hop header contract.

(Based on your team's feedback about scoping required headers by request type.) [FEEDBACK_USED]</violation>

<violation number="2" location="python/src/basecamp/_http.py:329">
P3: The new `DOWNLOAD_RETRY_ON` policy constant is defined identically in both `_http.py` and `_async_http.py`. This is a SPEC-policy value (not incidental), so any change to the hop-1 retry set must be edited in two places or the sync/async transports silently diverge in retry behavior. The same duplication already exists for `DEFAULT_RETRY_ON`, but this is the policy the new download feature depends on, so it is worth centralizing while the diff is fresh.</violation>
</file>

<file name="kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt">

<violation number="1" location="kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt:318">
P2: When hop 1 returns a retryable status ({429, 502, 503, 504}), the loop sleeps for backoff and starts the next attempt without discarding the prior HttpResponse. Ktor keeps the response channel and its underlying connection open until it's consumed or cancelled, so during backoff (which can be seconds, especially with Retry-After) this can tie up a connection from the pool, and concurrent downloads may end up forcing subsequent attempts onto new sockets. Consider consuming/cancelling the response body before calling delay().</violation>

<violation number="2" location="kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt:335">
P2: 429 responses with an HTTP-date `Retry-After` fall back to exponential delay instead of honoring the server throttle. Extend `parseRetryAfter` to parse RFC 7231 dates (or use a download-specific parser) before scheduling this retry.</violation>

<violation number="3" location="kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt:353">
P3: The new `DownloadAuthFailure` class duplicates the existing `AuthPhaseFailure` tag in BasecampHttpClient.kt — identical shape and identical purpose (tag an auth-strategy exception so it surfaces raw and spends no retry budget). Worth consolidating: promote `AuthPhaseFailure` to `internal` in the http package (already accessible from Download.kt) and reuse it here, so the auth-phase classification stays in one place rather than being re-declared per hop.</violation>
</file>

<file name="swift/Sources/Basecamp/HTTP/HTTPClient.swift">

<violation number="1" location="swift/Sources/Basecamp/HTTP/HTTPClient.swift:321">
P2: Hop-1 does not retry a transport failure already represented as `BasecampError.network`; this catch treats it as terminal. Classify `.network` as the network retry path while continuing to fail API/auth/usage errors immediately.</violation>
</file>

<file name="typescript/src/retry.ts">

<violation number="1" location="typescript/src/retry.ts:142">
P2: 429 responses with an HTTP-date `Retry-After` do not wait until the declared date; zero or negative values also bypass backoff. Parse positive deltas, then future HTTP-dates, and use exponential backoff for every other value.</violation>
</file>

<file name="typescript/src/download.ts">

<violation number="1" location="typescript/src/download.ts:205">
P2: A 429 download response with a valid HTTP-date `Retry-After` will retry after normal backoff instead of the server-requested time. Make this retry path use the spec-compliant positive-integer/HTTP-date parser (and fall back for zero or invalid values).</violation>
</file>

<file name="swift/Tests/BasecampTests/DownloadTests.swift">

<violation number="1" location="swift/Tests/BasecampTests/DownloadTests.swift:750">
P3: The new download retry tests incur real wall-clock backoff sleeps (~13s added to the suite) because performDownloadRequest hard-codes a 1s exponential base and 3 attempts with no test seam to shrink the delay. Consider injecting a delay clock or an internal/test-visible base-delay override for the download hop so the suite stays fast without exposing a public numeric knob; also add a timeout to the `while transport.requests.isEmpty` polling loop in the cancellation test to avoid a hang if the request never fires.</violation>
</file>

<file name="ruby/test/basecamp/download_test.rb">

<violation number="1" location="ruby/test/basecamp/download_test.rb:465">
P3: Consider pinning the "no backoff / no on_retry after the final doomed attempt" contract. Both new retry tests avoid exercising it: the balanced-hooks test ends with a 200 on attempt 3, and the exhaustion test (503x3, max_retries 3) only asserts request count, not hooks or sleep. Since the repo explicitly treats skipping the final retry sleep/on_retry as required behavior, a fully-failing variant that asserts on_retry == [2,3] and a single captured 429 delay would lock that in against regressions.</violation>
</file>

<file name="conformance/runner/python/runner.py">

<violation number="1" location="conformance/runner/python/runner.py:438">
P3: The retry-backoff verification is now limited to the first request gap. After this change (and un-skipping the download retry cases), the 503 download fixture — which has two retry gaps (503→503 and 503→302) before the terminal un-delayed redirect hop — only has its first retry gap checked. A regression that dropped the backoff on the second retry would no longer fail the conformance assertion, whereas the previous `any(d < min)` logic would have caught it. The redirect gap does need to be excluded, but it would preserve more coverage to require the minimum delay on every non-terminal gap (e.g. `any(d < min for d in delays[:-1])`) rather than only the first.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread python/src/basecamp/_async_http.py Outdated
Comment thread ruby/lib/basecamp/http.rb Outdated
Comment thread python/src/basecamp/_http.py Outdated
Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt
Comment thread swift/Sources/Basecamp/HTTP/HTTPClient.swift
Comment thread swift/Tests/BasecampTests/DownloadTests.swift
Comment thread ruby/test/basecamp/download_test.rb
Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt Outdated
Comment thread python/src/basecamp/_http.py
Comment thread conformance/runner/python/runner.py Outdated
jeremy added 2 commits July 31, 2026 20:52
…d Ruby

§14 has always said hop 1 sets Authorization and User-Agent only — "no
Accept or Content-Type — this is a binary download, not a JSON API call".
Python and Ruby never honored it: their download hop borrows the generic
request path, which stamps Accept: application/json on every request. Go,
TypeScript, Kotlin and Swift build the hop-1 headers themselves and were
already correct, so this was a two-SDK divergence against an explicit
clause, predating the retry work.

Both transports gain an accept seam threaded from the retry entry point
down to the header builder, defaulting to the JSON Accept so every other
caller is untouched; the download hop passes none. Python's 401-refresh
replay and Ruby's carry it through, so a refreshed retry does not
silently re-acquire the header.

What remains on the wire is the HTTP library's own default (httpx sends
Accept: */*, Faraday sends nothing) — not something the SDK sets. The
tests pin that honestly: Python asserts hop 1's Accept is identical to
the bare hop-2 client's and never application/json; Ruby asserts no
attempt carries the JSON Accept. Both fail against the un-fixed
transports with the header present on every hop-1 attempt.
The download hop-1 loop had declared its own DownloadAuthFailure, an
exact copy of BasecampHttpClient's AuthPhaseFailure — same shape, same
job: mark an auth-strategy throw so it surfaces raw and spends no retry
budget. Two copies means the classification can drift per hop, so
AuthPhaseFailure is now internal and the download loop uses it.

The Swift cancellation test spun on `while transport.requests.isEmpty`
with no ceiling, so a regression that never issued the first attempt
would hang the suite instead of failing it. It now gives up after five
seconds with a named failure.
jeremy added 2 commits July 31, 2026 20:54
rubocop's Rails/RefuteMethods prefers the assert_not_* spelling.
ruff's line-length allows the single-line call.
Copilot AI review requested due to automatic review settings August 1, 2026 04:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

… index

Two corrections from the fourth review pass.

The previous commit made the 401 token-refresh replay draw from the
transient-retry budget. That regressed SPEC §4, which is explicit: refresh
is attempted at most once PER REQUEST, tracked "with a boolean (e.g.
refresh_attempted) rather than a counter", and it is not subordinate to
the retry cap. Under the change, max_retries: 1 refreshed the token and
then rethrew the stale 401 without ever sending the refreshed request —
for every GET in Python and Ruby, not just downloads. Reverted.

The tension that prompted it is real but is a spec question, not a bug:
§4 replays once per request, §14 promises exactly one hop-1 attempt when
retry is disabled, and a 401 with retry off satisfies only one of them.
#565 is where that gets settled. Both SDKs now pin the behavior the spec
actually prescribes, so changing it later has to be a decision rather
than a drift.

The download fixtures also asserted a second retry gap that three runners
could not see: Go, TypeScript and Kotlin always measured
requestTimes[1] - requestTimes[0] and ignored the index, so an
implementation that dropped its second backoff still passed there. All
five runners now resolve the gap index, and a named gap that does not
exist fails rather than passing silently — otherwise a dropped retry
makes the assertion vanish instead of firing. Proven by raising the
gap-1 minimum: Go reports "Expected delay >= 16m39.999s at gap 1, got
2.028111541s", the second exponential backoff it previously never looked at.
Copilot AI review requested due to automatic review settings August 1, 2026 05:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aca2c481b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread conformance/runner/go/main.go

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12 issues found across 27 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="conformance/runner/typescript/runner.test.ts">

<violation number="1" location="conformance/runner/typescript/runner.test.ts:805">
P3: The new named-gap check can silently pass. Because the `expect(times.length).toBeGreaterThan(gap + 1)` guard only runs inside the existing `if (times.length >= 2)`, a fixture whose gap (even the default index 0) has fewer than 2 recorded requests skips the whole delay assertion instead of failing — contradicting the new 'fails rather than passing silently' comment and potentially masking a regression where the SDK stopped retrying. Consider hoisting the length check above the `if` so a missing gap always fails loudly.</violation>
</file>

<file name="conformance/runner/python/runner.py">

<violation number="1" location="conformance/runner/python/runner.py:442">
P2: An out-of-range `delayBetweenRequests.index` can crash the Python runner (large negative) or make its delay assertion pass without checking anything (large positive). Normalize negative gap indexes and record a failure when the selected gap is absent, matching the runner's indexed-request semantics.</violation>
</file>

<file name="conformance/runner/ruby/runner.rb">

<violation number="1" location="conformance/runner/ruby/runner.rb:539">
P2: An indexed delay assertion passes when its selected gap does not exist, masking a missing retry delay. Treat an out-of-range `index` as an assertion failure before comparing the delay.</violation>
</file>

<file name="python/src/basecamp/_http.py">

<violation number="1" location="python/src/basecamp/_http.py:131">
P1: Downloads with a refreshable-token 401 exceed the declared total-attempt cap: `max_retries=0`/`1` still sends a second Hop-1 request during the internal refresh replay. Account for that replay in the Hop-1 budget, or disable it for this policy, so retry-disabled downloads make exactly one request.</violation>
</file>

<file name="typescript/src/retry.ts">

<violation number="1" location="typescript/src/retry.ts:142">
P2: 429 responses with an HTTP-date `Retry-After` retry after the exponential delay instead of the server-requested time. Parse a valid HTTP-date after rejecting an invalid/nonpositive delta-seconds value; avoid `parseInt` partial parses such as `"1junk"` too.</violation>
</file>

<file name="kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt">

<violation number="1" location="kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt:418">
P2: Retry conformance cases with an omitted `index` validate only gap 0, not every inter-request gap required by the fixture schema. Preserve whether `index` was omitted and iterate all gaps in that case; otherwise an SDK can pass despite skipping or shortening later retry backoff.</violation>
</file>

<file name="kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt">

<violation number="1" location="kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt:321">
P3: Large allowed `maxRetries` values make hop-1 backoff overflow after the 55th failed attempt, so retries no longer use exponential delays and hooks receive a wrapped delay. Saturate/cap the backoff calculation (or bound the accepted retry count) before scheduling it.</violation>
</file>

<file name="conformance/runner/go/main.go">

<violation number="1" location="conformance/runner/go/main.go:952">
P2: The new bounds-check only runs when at least 2 requests were recorded (outer `if len(requestTimes) >= 2` guard stays), so a fully dropped retry that yields a single request still makes this delay assertion pass silently — the exact case the added comment claims to prevent. Consider removing the outer len>=2 guard and letting the `gap+1 >= len(requestTimes)` check fail whenever the requested gap has fewer recorded requests, so a vanished retry is pinned for the default gap to too.</violation>
</file>

<file name="python/tests/test_download.py">

<violation number="1" location="python/tests/test_download.py:354">
P3: The sync and async suites (`TestHop1Retry` and `TestHop1RetryAsync`) duplicate nearly all seven scenarios verbatim, differing only in `download_sync` vs `await download_async`, the transport helper, and a `pytest.mark.asyncio` marker. This doubles the surface area for the retry policy — any future policy change (retry set, backoff, cap semantics) has to be edited in two places or the suites silently drift apart. Consider sharing the scenarios through a transport-parameterized helper that runs the same table against both sync and async paths.</violation>
</file>

<file name="typescript/tests/download.test.ts">

<violation number="1" location="typescript/tests/download.test.ts:562">
P3: This test adds a real 1-second sleep and asserts on wall-clock elapsed time, which is inherently timing-sensitive and slows the suite. Since the retry loop already passes the computed delay to the onRetry hook, you can verify Retry-After deterministically and instantly by returning 429 with `Retry-After: 1` and asserting the onRetry delayMs is 1000 (the Retry-After path skips jitter), dropping the `performance.now()` threshold check entirely.</violation>
</file>

<file name="swift/Tests/BasecampTests/DownloadTests.swift">

<violation number="1" location="swift/Tests/BasecampTests/DownloadTests.swift:782">
P3: These new download tests couple correctness to wall-clock scheduling: two use hardcoded 5s polling budgets (1ms step) to detect when a hop-1 attempt reaches the transport, and the Retry-After test asserts `elapsed >= 2.0` against a real 2s sleep. On a loaded CI runner, a delayed first attempt can trip the fixed `waited < 5_000` budget and fail spuriously, and the cumulative sleeps add ~7s of serial latency to every run. Consider loosening the poll budget (retry a few times proportionally rather than a fixed bound) or restructuring to avoid the timing assertions, so retry semantics are asserted via hook/counter state rather than elapsed time.</violation>
</file>

<file name="swift/Sources/Basecamp/HTTP/HTTPClient.swift">

<violation number="1" location="swift/Sources/Basecamp/HTTP/HTTPClient.swift:201">
P2: Cancelling an in-flight URLSession request can still fire `onRetry` and be classified as a retryable network failure because URLSession cancellation is `URLError(.cancelled)`, not necessarily `CancellationError`. Treat `URLError.cancelled` (and task cancellation) as terminal in both retry loops.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread python/src/basecamp/_http.py
Comment thread conformance/runner/python/runner.py
Comment thread conformance/runner/ruby/runner.rb
Comment thread typescript/src/retry.ts
Comment thread conformance/runner/typescript/runner.test.ts
Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/Download.kt
Comment thread python/tests/test_download.py
Comment thread typescript/tests/download.test.ts
Comment thread swift/Tests/BasecampTests/DownloadTests.swift
@jeremy

jeremy commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Bot-review status: cubic delivered two substantive rounds (1×P1, 6×P2, 4×P3 in the second) — all addressed or refuted with evidence and every thread resolved; no suppressed-comment blocks in any review body. Codex posted no findings; Copilot errored, as it has on every PR in this series. Merging on convergence + green CI per the standing convention.

@jeremy
jeremy merged commit 91f6207 into main Aug 1, 2026
45 of 46 checks passed
@jeremy
jeremy deleted the feat/download-hop1-retry branch August 1, 2026 06:16
jeremy added a commit that referenced this pull request Aug 1, 2026
* origin/main:
  Bare field-map bodies, Swift's structured slot, and two re-tagged emitters (#549)
  Retry the authenticated download hop under a declared set, in every SDK (#563)
  Register the Folders API gap (bc3 #12384) and supersede stack-doc-and-smithy (#559)
jeremy added a commit that referenced this pull request Aug 1, 2026
…annot reach

Second review round on this PR. Five findings from cubic, two from the B4
agent, and one class the round exposed that neither had named: the runners'
assertion logic had no tests of its own, so every bounds branch here was
written blind and verified only by fixtures that pass.

An index of Int.MAX_VALUE crashed the Kotlin runner instead of failing its
assertion: `gap + 1 >= size` does the addition first, so the value wraps
negative and sails through the guard into an out-of-bounds read. Go had the
identical shape. Both now compare against the gap count and never add. Ruby
and Python were already safe — they compare against the gap count directly.

TypeScript did not reject a negative gap index at all: `toBeGreaterThan(index
+ 1)` is trivially satisfied by a negative, so `times[-1]` produced NaN and a
message about a delay rather than an out-of-range index — contradicting what
the other four runners did. All five now distinguish the two failures: a
negative index is refused categorically, which has nothing to do with how many
requests were made.

A `min` of zero silently disabled the whole assertion in Python and Ruby. Both
gated the check on the value's truthiness, and `0` is falsy in Python while an
absent `min` is nil in both — so `{"type": "delayBetweenRequests", "min": 0}`
asserted nothing at all. That is the exact false-green class this PR exists to
kill, reintroduced one layer up. The default now lands INSIDE the checked
function rather than at the call site, so no caller can gate it away, and a
zero minimum still requires the gap to EXIST.

Making the Kotlin index nullable to tell "omitted" from "0" also regressed
diagnostics: requestPath, requestMethod, requestBody and the header assertions
interpolated `assertion.index` into their failure messages, which printed
`index null` for an assertion that had actually used index 0. Each branch now
resolves the index once and reports the resolved value.

The Swift cancellation test caught every error and asserted nothing about it,
so it would have passed even if the cancellation were wrapped as
BasecampError.network — the exact regression it exists to catch. It now
requires URLError(.cancelled) to arrive raw. Inserting isCancellation above
sleepNanoseconds had also orphaned a doc comment, leaving generated
documentation claiming isCancellation clamps Retry-After; the comments are
reordered onto their own functions.

Underneath all of that: the delayBetweenRequests contract is now one function
per runner (checkDelayGaps / DelayGaps.check / check_delay_gaps) with a
committed unit test beside it, wired into `make check` and CI through a new
conformance-runner-tests target. Ten cases per runner, each named for a
behavior that regressed here or on #563 — omitted index catching a LATER
failing gap, omitted index with zero gaps failing, a named gap the run never
produced failing, a negative index rejected, an enormous index failing without
overflow, and a zero minimum still requiring the gap to exist. The runners are
test harnesses, but their assertion logic is code, and its bounds branches
never execute against a fixture that passes — which is precisely how #563
shipped an assertion that vacuously passed.

One residual false-green closed with them: an omitted index over zero gaps
passed in all five runners while checking nothing. Existing fixtures happen to
pair the delay assertion with requestCount, which limited the exposure, but the
contract itself was unsound and #558 is about to build conformance claims on
it.

conformance/schema.json and SPEC §19 now state the whole contract: never
vacuous, bounds-checked unconditionally, negatives rejected.
jeremy added a commit that referenced this pull request Aug 1, 2026
…sion's cancellation (#568)

* Make the gap index bite in every runner and catch URLSession's cancellation

Three follow-ups from the fourth review pass, all on code this branch added.

The schema documents an omitted delayBetweenRequests index as "require
the minimum on every gap", but Go, TypeScript and Kotlin still measured
only gap 0 — so the documentation promised coverage three runners did not
provide. All five now iterate every gap when the index is omitted.

A NAMED gap that does not exist now fails in all five. The bounds check
had been sitting inside each runner's "did we record two requests" guard,
so a fully dropped retry left one request and the assertion evaporated
instead of firing — the worst outcome for a timing pin. Ruby and Python
additionally reject a negative index rather than wrapping to the end the
way the per-request assertions do; there is no sensible "last gap"
semantic when the point is to name a specific backoff.

Swift only recognised Swift-concurrency cancellation. URLSession reports
a cancelled task as URLError(.cancelled), not CancellationError, so a
genuinely cancelled download was still classified a retryable network
blip and spent the whole budget. Both loops now test for either shape
through a shared isCancellation, pinned by a test that fails four ways
against the previous commit.

* Make the gap-index bounds bite, and unit-test the branches fixtures cannot reach

Second review round on this PR. Five findings from cubic, two from the B4
agent, and one class the round exposed that neither had named: the runners'
assertion logic had no tests of its own, so every bounds branch here was
written blind and verified only by fixtures that pass.

An index of Int.MAX_VALUE crashed the Kotlin runner instead of failing its
assertion: `gap + 1 >= size` does the addition first, so the value wraps
negative and sails through the guard into an out-of-bounds read. Go had the
identical shape. Both now compare against the gap count and never add. Ruby
and Python were already safe — they compare against the gap count directly.

TypeScript did not reject a negative gap index at all: `toBeGreaterThan(index
+ 1)` is trivially satisfied by a negative, so `times[-1]` produced NaN and a
message about a delay rather than an out-of-range index — contradicting what
the other four runners did. All five now distinguish the two failures: a
negative index is refused categorically, which has nothing to do with how many
requests were made.

A `min` of zero silently disabled the whole assertion in Python and Ruby. Both
gated the check on the value's truthiness, and `0` is falsy in Python while an
absent `min` is nil in both — so `{"type": "delayBetweenRequests", "min": 0}`
asserted nothing at all. That is the exact false-green class this PR exists to
kill, reintroduced one layer up. The default now lands INSIDE the checked
function rather than at the call site, so no caller can gate it away, and a
zero minimum still requires the gap to EXIST.

Making the Kotlin index nullable to tell "omitted" from "0" also regressed
diagnostics: requestPath, requestMethod, requestBody and the header assertions
interpolated `assertion.index` into their failure messages, which printed
`index null` for an assertion that had actually used index 0. Each branch now
resolves the index once and reports the resolved value.

The Swift cancellation test caught every error and asserted nothing about it,
so it would have passed even if the cancellation were wrapped as
BasecampError.network — the exact regression it exists to catch. It now
requires URLError(.cancelled) to arrive raw. Inserting isCancellation above
sleepNanoseconds had also orphaned a doc comment, leaving generated
documentation claiming isCancellation clamps Retry-After; the comments are
reordered onto their own functions.

Underneath all of that: the delayBetweenRequests contract is now one function
per runner (checkDelayGaps / DelayGaps.check / check_delay_gaps) with a
committed unit test beside it, wired into `make check` and CI through a new
conformance-runner-tests target. Ten cases per runner, each named for a
behavior that regressed here or on #563 — omitted index catching a LATER
failing gap, omitted index with zero gaps failing, a named gap the run never
produced failing, a negative index rejected, an enormous index failing without
overflow, and a zero minimum still requiring the gap to exist. The runners are
test harnesses, but their assertion logic is code, and its bounds branches
never execute against a fixture that passes — which is precisely how #563
shipped an assertion that vacuously passed.

One residual false-green closed with them: an omitted index over zero gaps
passed in all five runners while checking nothing. Existing fixtures happen to
pair the delay assertion with requestCount, which limited the exposure, but the
contract itself was unsound and #558 is about to build conformance claims on
it.

conformance/schema.json and SPEC §19 now state the whole contract: never
vacuous, bounds-checked unconditionally, negatives rejected.

* Report the real request count, and install the Ruby bundle before its helper test

Third review round, two findings.

The out-of-range diagnostic in Python and Ruby inferred the request count as
`len(delays) + 1`. That inference assumes at least one request was made, so a
run that failed during construction or dispatch — before the tracker recorded
anything — reported "only 1 request(s) were made" when the true count was
zero, pointing at the wrong failure. Both now take the count from the tracker
and report it; Go, TypeScript and Kotlin already measured request times
directly and were accurate.

`make conformance-runner-tests` also ran `bundle exec` in the Ruby runner
before anything had installed its gems. `make conformance` reaches the helper
tests ahead of the `conformance-ruby` recipe that does the install, and
invoking the target on its own had no installer at all, so a clean checkout hit
a Bundler error instead of the test. It now mirrors the runner recipe with
`bundle install --quiet` first.
jeremy added a commit that referenced this pull request Aug 1, 2026
…EC §4 to match (#571)

* Count the 401 refresh replay against the attempt budget, and amend SPEC §4 to match

`max_retries: 0` documented one attempt and sent two. A refreshable 401 put a
second request on the wire from inside the single-request primitive, governed
by its own counter rather than the caller's budget — so the promise §14 makes
for downloads ("disabling retry yields exactly ONE hop-1 attempt") was false in
Python and Ruby, and the total-attempt semantics #461 settled leaked.

Counting the replay was tried once on #563 and reverted, correctly: on its own
it regresses SPEC §4, which says refresh is attempted once PER REQUEST, tracked
"with a boolean rather than a counter". Gated only by "have we already
refreshed", `max_retries: 1` refreshed the token and then rethrew the stale 401
without ever sending the refreshed request — for every GET, not just downloads.
That was worse than the bug.

So this lands both halves together, which is what makes it a decision rather
than a drift. The replay counts against the budget, AND §4 is amended to
attempt the refresh only when another attempt remains. The gate is checked
BEFORE refresh() rather than after: rotating a token the SDK has no budget left
to use burns it for nothing and hands the caller the stale 401 anyway, so
declining to refresh is both cheaper and easier to state. This supersedes §4's
unqualified per-request reading and closes #565.

The consequence is stated plainly in §4 rather than left to be discovered: with
a budget of one attempt, a refreshable 401 is NOT replayed and surfaces as
auth_required. Callers who want the replay must leave an attempt for it.

Scope is Python (sync and async) and Ruby — the two SDKs whose transports carry
a 401 replay under a governed budget. Go's main GET loop already counts it: a
401 with a successful refresh returns a retryable error that the loop re-issues
as the next attempt, subject to MaxRetries. Its hand-written mutation path
replays outside any budget, but mutations have no transient-retry budget to
draw from — that is the documented divergence in §7, and §4's new gate says
explicitly that it binds where a total-attempt budget governs the path.
TypeScript, Kotlin and Swift have no 401 replay in the transport at all.

Direct single-request callers keep the in-primitive replay. Python's and Ruby's
mutations bypass the retry loop entirely, so without the seam they would lose
401 refresh outright; `refresh_replay:` marks which side owns it. In Ruby that
also stops handle_error from rotating credentials as a side effect of
classifying an error — the refresh now happens where the budget is known.

Red-proofed against merged main on the plain GET path as well as the download
hop, since the every-GET case is exactly what the earlier revert protected:

    tests/test_http.py::TestRefreshReplayAttemptBudget::test_no_budget_means_one_request_and_no_refresh
    E       AssertionError: assert 2 == 1
    tests/test_http.py::TestRefreshReplayAttemptBudget::test_replay_and_transient_retries_share_one_budget
    tests/test_download.py::TestHop1Retry::test_refresh_replay_is_not_attempted_without_budget

and in Ruby:

    The request GET https://3.basecampapi.com/test.json was expected to execute 1 time but it executed 2 times
    The request GET https://3.basecampapi.com/test.json was expected to execute 2 times but it executed 3 times
    The request GET .../download/file.txt was expected to execute 1 time but it executed 2 times

The middle one is the load-bearing case: at `max_retries: 2` the replay used to
ride outside the cap for a total of three requests. Positive tests pin that the
refreshed request is actually SENT rather than refreshed-then-discarded, and
that mutations keep their replay.

* Classify in the handler, refresh outside it, so a failing token endpoint still retries

An exception raised inside an `except` suite is not offered to that `try`'s
sibling handlers — same in Ruby for `rescue`. Calling refresh() from inside the
AuthError handler therefore put the token endpoint outside the retry loop's
reach: a NetworkError from a timing-out refresh escaped the whole loop and
ended the request with budget still unspent. Before this branch the replay
lived in the single-request primitive, where that failure landed in the
transient handler and retried, so this was a regression introduced by moving
it.

Both loops now follow the shape the Swift and Kotlin download loops already use
(#517): the handlers only CLASSIFY the attempt, and every side effect runs at
the loop tail, outside any handler. A refresh that raises is classified as a
transient failure of this attempt and retries under the same budget; a refresh
that returns false surfaces the original 401; a refresh that succeeds replays
immediately with no backoff, since the token is fresh and the server never
asked us to wait.

Red-proofed against the previous commit in both languages — the refresh's
NetworkError propagated raw out of `client.get(...)` rather than being retried:

    E       basecamp.errors.NetworkError: token endpoint timed out
    FAILED tests/test_http.py::TestRefreshReplayAttemptBudget::test_refresh_network_failure_still_retries_under_the_budget

* Spend the one allowed refresh on the attempt, not on the success

SPEC §4 tracks refresh with an "attempted" boolean — the algorithm's own
condition is "refresh has not yet been attempted for this request" — but the
flag was set only after a refresh returned true. A refresh that RAISES left it
false, so the next 401 in the same request called the provider again.

Reachable as soon as the token endpoint is flaky: attempt 1 401s, refresh
throws a NetworkError, the attempt retries under the budget, attempt 2 401s
with the same unchanged token, and refresh fires a second time. Beyond
violating the at-most-once rule, that is unsafe with a rotating refresh token —
if the first call reached the server and rotated before its response was lost,
the second spends a credential that is already dead.

All three paths now mark the attempt before invoking the provider. Red-proofed
against the previous commit, with a provider that always throws and a server
that always 401s:

    >       assert provider.refreshes == 1
    E       assert 2 == 1

The existing network-failure tests missed this because their second request
returns 200, so no second 401 ever arrives to trigger the second refresh.
jeremy added a commit that referenced this pull request Aug 1, 2026
A verification sweep found the new runner reporting green on things it was
not checking. A conformance runner that does that is worse than no runner:
it converts an untested SDK into a tested-looking one.

The delayBetweenRequests evaluator measured gap 0 and ignored the
assertion's index, and skipped the check entirely below two requests — the
identical defect #568 had just fixed in the other five. It mattered here and
not hypothetically: the two download-retry fixtures this branch un-skips
carry index 0 AND index 1 assertions, so the second backoff was never
measured. checkDelayGaps moves into an SDK-free ConformanceSupport target
with #568's semantics and its committed test roster ported; 12 of the 13
cases fail against the evaluator as shipped. An executable target carrying
@main cannot host XCTest, which is why the split exists.

The two download skips are gone. #563 landed the authenticated hop-1 retry,
and both cases now pass live rather than being deleted on faith. Nothing
capability-shaped is skipped any more: the roster is empty and the only
standing exclusion is the architectural link-header branch Kotlin and
TypeScript share.

The rest are review findings on this branch's own code, each a way a
malformed fixture could have passed:

- A case with no assertions ran an operation and verified nothing. An empty
  assertions array is schema-legal, so the fixture gate does not catch it.
- An absent mockResponses key decoded as an empty queue, which is a
  deliberate declaration for the HTTPS case and a malformed fixture
  everywhere else. The two no longer collapse.
- networkError: false alongside a status slipped past the exactly-one-of
  backstop and was served as a plain success. The schema pins the literal
  true; so does the runner now.
- Path parameters coerced to 0 when missing or non-integral, so the request
  went to a different resource and the scripted queue answered it anyway.
  They throw. The two timesheet arms used a `== 0` sentinel to pick between
  two spellings of one key, which could not tell an absent key from an id of
  zero; they ask for the first key present instead.
- configOverrides.maxItems reaches the SDK through one dispatch arm. Any
  other operation would have paginated unbounded while the fixture believed
  it had capped the walk, so that now fails loudly instead.
- headerInjected ignored its index and validated the first request, which
  the schema documents as index-aware and the other runners implement.
- errorType: "ambiguous" could never pass despite Swift mapping it.
- An explicit expected: null compared nil against the literal "null" and
  always failed.
- The HTTPS probe routed only http:// to the crash child, but the SDK traps
  on every non-HTTPS scheme outside the localhost carve-out, so an ftp:// or
  ws:// fixture would have taken the whole run down mid-suite.
- SWIFT_CONFORMANCE_NO_SKIPS was enabled by the variable's presence, so an
  inherited empty value silently changed coverage. It compares to "1".

One of these was not a runner bug at all. The single-key array unwrap that
lets list fixtures decode also rewrote {"payload_url": ["is not a valid
URL"]} into a bare array, so the SDK found no field errors and reported
"bad request" — a false FAIL, and the same heuristic could as easily
manufacture a pass. Kotlin took the success-only status guard in #549; this
port predated it and now carries it too.

SPEC retires the "where a runner does not exist yet, e.g. Swift" carve-out,
adds swift to the §21 gate roster, and rosters the one architectural skip.
jeremy added a commit that referenced this pull request Aug 1, 2026
Swift was the only SDK not fixture-verified; the #508/#509 retry-lifecycle bugs lived exactly where no runner watched. This adds the sixth conformance runner, executing every mock fixture in conformance/tests/ through the SDK's public Transport seam — no @testable anywhere. 136 passed, 0 failed, 1 skipped of 137; the one skip is the architectural link-header line Kotlin and TypeScript share.

Seven rounds of review turned it into the opposite of what it started as. The runner shipped with the same delayBetweenRequests defect #568 had just fixed in the other five — gap 0 only, index ignored, check skipped below two requests — which mattered because the two download fixtures this branch un-skips carry index 0 AND index 1. checkDelayGaps moves into an SDK-free ConformanceSupport target with #568's semantics and its test roster ported; 12 of 13 cases fail against the evaluator as shipped.

Sixteen more findings, each a way a fixture could pass while testing nothing: a case with no assertions, an absent response queue, networkError:false, path params coerced to 0, maxItems silently ignored, headerInjected ignoring its index, errorType ambiguous unreachable, expected:null always failing, the HTTPS probe crashing the run on a non-http scheme, NO_SKIPS triggering on presence, requestCount as a lower bound that made the pagination cap assertions unfailable, no path check at all, an exemption too coarse to cover request zero, path and method checked on the first hop only, an unscoped request accepted, the query string dropped so refetching page 1 looked like following the link, and an operation short-circuited before the transport skipping every invariant at once. Eight carry a red proof against the un-fixed code.

One was not a runner bug: the single-key array unwrap rewrote error bodies, so a bare field map decoded as an array and the SDK reported 'bad request'. Kotlin took the success-only status guard in #549; this port predated it.

The two download-retry skips are deleted and pass live under SWIFT_CONFORMANCE_NO_SKIPS=1, #563 having landed the hop-1 retry. Five shared download fixtures gained indexed requestPath assertions, so all six runners now pin the redirect target rather than only counting hops.

CI: test-swift runs the runner unit tests and the conformance suite on macos-15, and is in the required Conformance Tests fan-in's needs. SPEC retires the 'where a runner does not exist yet, e.g. Swift' carve-out, adds swift to the §21 gate roster, and rosters the one architectural skip.

Follow-up: #573 (Kotlin carries the same requestCount lower bound).
@jeremy

jeremy commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Disposition of the 13 review threads left open when this merged.

This PR was merged prematurely on a truncated thread query (reviewThreads(first: N) silently paged out a 67-thread PR), so these findings were never adjudicated here. Every one has now been traced. Nothing was dropped:

Fixed in #568 (144320cd0) — the conformance delay-gap contract, repaired in all five runners with an extracted checkDelayGaps plus committed unit tests wired into make check and four CI jobs:

  • Check every gap when the delay index is omitted (Codex, go/main.go)
  • Out-of-range index crashes or vacuously passes (python/runner.py)
  • Indexed assertion passes when the gap doesn't exist (ruby/runner.rb)
  • Omitted index validates only gap 0 (kotlin Main.kt)
  • Bounds check only runs at ≥2 requests (go/main.go)
  • Named-gap check can silently pass (ts runner.test.ts)
  • Cancelled URLSession request still fires onRetry (swift HTTPClient.swift) — fixed via URLError(.cancelled) classification

Fixed in #571 (573ebecb5), closing #565:

  • 401 refresh replay exceeds the declared total-attempt cap (python _http.py). The replay now counts against the budget and SPEC §4 gained "and the attempt budget has another attempt left", with the gate checked before refresh() so a rotation is never burned on an attempt that cannot happen.

Tracked, not fixed:

Judged test-hygiene, no action: the sync/async duplication in test_download.py, and the wall-clock coupling in download.test.ts and DownloadTests.swift. Real observations, but they describe test ergonomics rather than shipped behavior; the timing-sensitivity concern is mitigated by the monotonic-clock work in #530.

Resolving all 13 against these dispositions. The durable lesson is recorded: a census must cursor-paginate, take its count from totalCount, and gate on unresolved rather than unresolved-and-not-outdated — a push moves diff hunks and flips open threads to outdated, which is exactly how a real finding hides.

jeremy added a commit that referenced this pull request Aug 3, 2026
…d six runner unit tests

Three verifier findings on #597, all about the new assertion proving less
than it claimed.

The Cards kill cases did not discriminate in TypeScript. The generated
updateVerbatim guards due_on with /^\d{4}-\d{2}-\d{2}$/.test(req.dueOn), and
RegExp.test coerces its argument to a string first, so ["x"] ("x") and {}
("[object Object]") were already rejected before the PUT with or without the
guard -- TypeScript conformance failed 2 kill cases against the unfixed
composites, not 4, and TypeScript Cards had no regression protection at all.
A fifth case uses ["2024-02-01"], which String() renders as exactly
"2024-02-01": the format check waves it through and only the guard stops it.
It still discriminates in Python and Ruby, and Go, Kotlin and Swift reject it
structurally as they do any JSON array in a String field, so the shared
fixture stays shared.

The errorRaised handler had no unit test in any runner. Its failing branch is
unreachable from conformance/tests/ -- every case declaring it is one the SDK
does refuse -- so a handler that accepted everything would report green in all
six runners at once, which is how #563 shipped a vacuous delayBetweenRequests
check. The predicate is split out per runner and tested on both directions,
with the message pinned verbatim in all six. Go also asserts the wiring, since
a typo'd case label would fall through to the default and assert nothing.

evaluateAssertions(dispatchFailed:) loses its default. The one call site passes
it, but the default fails closed: a future call site that omitted it would
report "the call succeeded" on a call that did not, reddening every errorRaised
fixture far from the actual bug.

Also fixes the Codex P2: the Swift HTTPS-enforcement probe recorded caughtError
without setting dispatchFailed, so a trapped child process read as a successful
call. Runner.swift now flags it, and both Swift and Kotlin derive the assertion
from the union of the two signals rather than from call-site discipline.
jeremy added a commit that referenced this pull request Aug 3, 2026
…d six runner unit tests

Three verifier findings on #597, all about the new assertion proving less
than it claimed.

The Cards kill cases did not discriminate in TypeScript. The generated
updateVerbatim guards due_on with /^\d{4}-\d{2}-\d{2}$/.test(req.dueOn), and
RegExp.test coerces its argument to a string first, so ["x"] ("x") and {}
("[object Object]") were already rejected before the PUT with or without the
guard -- TypeScript conformance failed 2 kill cases against the unfixed
composites, not 4, and TypeScript Cards had no regression protection at all.
A fifth case uses ["2024-02-01"], which String() renders as exactly
"2024-02-01": the format check waves it through and only the guard stops it.
It still discriminates in Python and Ruby, and Go, Kotlin and Swift reject it
structurally as they do any JSON array in a String field, so the shared
fixture stays shared.

The errorRaised handler had no unit test in any runner. Its failing branch is
unreachable from conformance/tests/ -- every case declaring it is one the SDK
does refuse -- so a handler that accepted everything would report green in all
six runners at once, which is how #563 shipped a vacuous delayBetweenRequests
check. The predicate is split out per runner and tested on both directions,
with the message pinned verbatim in all six. Go also asserts the wiring, since
a typo'd case label would fall through to the default and assert nothing.

evaluateAssertions(dispatchFailed:) loses its default. The one call site passes
it, but the default fails closed: a future call site that omitted it would
report "the call succeeded" on a call that did not, reddening every errorRaised
fixture far from the actual bug.

Also fixes the Codex P2: the Swift HTTPS-enforcement probe recorded caughtError
without setting dispatchFailed, so a trapped child process read as a successful
call. Runner.swift now flags it, and both Swift and Kotlin derive the assertion
from the union of the two signals rather than from call-site discipline.
jeremy added a commit that referenced this pull request Aug 3, 2026
…d six runner unit tests

Three verifier findings on #597, all about the new assertion proving less
than it claimed.

The Cards kill cases did not discriminate in TypeScript. The generated
updateVerbatim guards due_on with /^\d{4}-\d{2}-\d{2}$/.test(req.dueOn), and
RegExp.test coerces its argument to a string first, so ["x"] ("x") and {}
("[object Object]") were already rejected before the PUT with or without the
guard -- TypeScript conformance failed 2 kill cases against the unfixed
composites, not 4, and TypeScript Cards had no regression protection at all.
A fifth case uses ["2024-02-01"], which String() renders as exactly
"2024-02-01": the format check waves it through and only the guard stops it.
It still discriminates in Python and Ruby, and Go, Kotlin and Swift reject it
structurally as they do any JSON array in a String field, so the shared
fixture stays shared.

The errorRaised handler had no unit test in any runner. Its failing branch is
unreachable from conformance/tests/ -- every case declaring it is one the SDK
does refuse -- so a handler that accepted everything would report green in all
six runners at once, which is how #563 shipped a vacuous delayBetweenRequests
check. The predicate is split out per runner and tested on both directions,
with the message pinned verbatim in all six. Go also asserts the wiring, since
a typo'd case label would fall through to the default and assert nothing.

evaluateAssertions(dispatchFailed:) loses its default. The one call site passes
it, but the default fails closed: a future call site that omitted it would
report "the call succeeded" on a call that did not, reddening every errorRaised
fixture far from the actual bug.

Also fixes the Codex P2: the Swift HTTPS-enforcement probe recorded caughtError
without setting dispatchFailed, so a trapped child process read as a successful
call. Runner.swift now flags it, and both Swift and Kotlin derive the assertion
from the union of the two signals rather than from call-site discipline.
jeremy added a commit that referenced this pull request Aug 3, 2026
* Refuse a malformed GET field instead of writing it back (#576)

The shipped Todos and Cards merge-safe composites in Python, Ruby and
TypeScript read each writable field off a GET and PUT the FULL
representation back. Every value read is therefore a value written -- on a
call that never mentioned the field -- and none of the three validated what
they read. Two failure modes, the same defect wearing different clothes:

  erasure    a falsey non-string coalesced away, wiping the field
  corruption a non-string forwarded verbatim, writing a number, boolean,
             array or object where a string belongs

Probed against the unfixed code, one call each, `update(content:)` and
`update(title:)`:

  Python Todos    description=False,0,[],{}  -> PUT description=""
                  description=42,True,["x"]  -> PUT description=42 / True / ["x"]
                  assignees[0].id="100"      -> PUT assignee_ids=["100"]
  Python Cards    due_on=False,0,[],{}       -> PUT with due_on OMITTED, which is
                                               exactly how BC3 erases the date
                  due_on=42,True,["x"]       -> PUT due_on=42 / true / ["x"]
  Ruby Todos      description=false          -> PUT description=""
                  description=0,[],{},42,... -> PUT description=0 / [] / {} / 42
  Ruby Cards      every shape                -> PUT due_on=<shape verbatim>
  TS Todos        all eight shapes           -> PUT description=<shape verbatim>
  TS Cards        due_on=false,0             -> PUT with due_on OMITTED (erased)

All three now treat an absent key or an explicit null as genuinely empty,
pass an actual string verbatim, and raise before the PUT naming the field.
The ID-list fields get the analogous check: an array, of objects, each with
an integer id. One level up, the response itself must be an object -- on
main a scalar or null body produced a raw TypeError/AttributeError instead
of the documented statusless api_error.

The rule underneath: a composite is safe exactly when a decoder REJECTS a
wrong-typed field at runtime, not when a type merely claims one. Go
(json.Unmarshal) and Swift (Codable) genuinely refuse. TypeScript's
schema.d.ts is erased at build time and the generated Python and Ruby
services return an untyped dict/Hash, so those three do it by hand, in a
shared per-language helper (_merge_safe.py, merge_safe.rb, merge-safe.ts)
rather than six copies.

Kill coverage lands in the SHARED conformance fixtures, not per language.
This defect survived five consecutive review passes because each pass fixed
one instance; a shared fixture catches every instance at once, in every
runner, permanently. Four cases across todos_write.json and cards_write.json
assert errorRaised + requestCount 1 -- the guard must fire BEFORE the PUT,
because a guard that fires after has already lost the field.

errorRaised is a new assertion type, the code-agnostic inverse of noError:
the six SDKs refuse the same body by two different mechanisms (hand-written
guard vs model decoder) that share no canonical error code. Declaring it
also tells the Kotlin and Swift runners that a decoder rejection is the
point of the case rather than an under-specified fixture body.

Writing that fixture immediately earned its keep: it found a FOURTH affected
language. Kotlin's client-wide `Json { isLenient = true }` coerces a JSON
scalar into a String field, so `"description": 42` decodes to "42" and the
composite writes it back -- proven on the wire with a temporary
requestBody assertion. #576 lists Kotlin as structurally safe; it is not,
for scalars. It cannot be fixed by this PR's pattern either, since the
coercion happens at decode and the composite only ever sees a String, so
the fixtures use array/object shapes (which kotlinx.serialization does
reject) and the scalar hole is filed separately.

Red proof, against unfixed composites: 85 Python, 81 Ruby, 78 TypeScript
unit failures, and 4/4/2 conformance kill-case failures (py/rb/ts). Go,
Kotlin and Swift pass the kill cases both before and after, which is the
point.

Deliberately out of scope: the caller-side mirror (a closure assigning 42
inside edit), the Kotlin lenient-decoder hole, and the generated validating
layer that would make all of these guards deletable (#578).

* Close the errorRaised coverage gaps: a TS-discriminating kill case and six runner unit tests

Three verifier findings on #597, all about the new assertion proving less
than it claimed.

The Cards kill cases did not discriminate in TypeScript. The generated
updateVerbatim guards due_on with /^\d{4}-\d{2}-\d{2}$/.test(req.dueOn), and
RegExp.test coerces its argument to a string first, so ["x"] ("x") and {}
("[object Object]") were already rejected before the PUT with or without the
guard -- TypeScript conformance failed 2 kill cases against the unfixed
composites, not 4, and TypeScript Cards had no regression protection at all.
A fifth case uses ["2024-02-01"], which String() renders as exactly
"2024-02-01": the format check waves it through and only the guard stops it.
It still discriminates in Python and Ruby, and Go, Kotlin and Swift reject it
structurally as they do any JSON array in a String field, so the shared
fixture stays shared.

The errorRaised handler had no unit test in any runner. Its failing branch is
unreachable from conformance/tests/ -- every case declaring it is one the SDK
does refuse -- so a handler that accepted everything would report green in all
six runners at once, which is how #563 shipped a vacuous delayBetweenRequests
check. The predicate is split out per runner and tested on both directions,
with the message pinned verbatim in all six. Go also asserts the wiring, since
a typo'd case label would fall through to the default and assert nothing.

evaluateAssertions(dispatchFailed:) loses its default. The one call site passes
it, but the default fails closed: a future call site that omitted it would
report "the call succeeded" on a call that did not, reddening every errorRaised
fixture far from the actual bug.

Also fixes the Codex P2: the Swift HTTPS-enforcement probe recorded caughtError
without setting dispatchFailed, so a trapped child process read as a successful
call. Runner.swift now flags it, and both Swift and Kotlin derive the assertion
from the union of the two signals rather than from call-site discipline.

* Keep the errorRaised kill fixtures honest with a control-sibling gate

Codex P2 on Runner.swift: declaring errorRaised switches OFF the #555
stop-on-mismatch policy in the decoder-backed runners. Swift's DecodingError
branch and Kotlin's MissingFieldException/SerializationException branches
normally fail loudly when a mock body no longer decodes into the generated
model; when the fixture declares errorRaised they treat the refusal as the
behaviour under test and pass. So if a model later gains a required field, or
any unrelated field in one of these large bodies drifts, the decode fails for a
reason unrelated to the field under test -- and errorRaised, requestCount: 1,
requestMethod and requestPath all still hold. The case keeps passing and stops
proving anything.

The protection turns out to already exist, structurally: every kill body is a
passing case's body with exactly one field perturbed, and that sibling does NOT
declare errorRaised, so it keeps the full #555 policy and fails loudly on
drift. Cards kill cases pair with update-preserves-due-on (differing only in
due_on), Todos with update-merge (differing only in description).

But nothing enforced the coupling -- edit one body without the other and it
silently breaks. So enforce the claim rather than asserting it in a comment,
which is the #576 lesson applied to #576's own fixtures: for every case
declaring errorRaised, some case in the same file that does not declare it must
have a mock body with the identical key set, differing in exactly one field.

Runs in make conformance-fixtures-check, which CI already invokes. Proven
non-vacuous: perturbing a kill body in a second field fails the gate (exit 1)
and names the case, the missing control and the repair.

* Pin the control gate to the response the kill case actually decodes

Codex P2 on the gate added a commit ago: it matched a kill body against ANY
queued response of ANY control case. Every kill case queues two object-shaped
responses -- response 0 is the GET whose decoder rejection is under test, and
response 1 is a decoy, queued so a runner cannot pass by exhausting the queue
instead of refusing the field. The decoy is never consumed. So an unconsumed
decoy could satisfy the gate while the body that actually gets decoded drifted
away from its control, which is the same vacuity the gate exists to prevent,
one level up.

Reachable, not theoretical. Drift the consumed body by a second field and make
the decoy differ from its control by exactly one, and the two gates split
cleanly: the old one reports `ok` at exit 0, the new one fails at exit 1
naming the case.

Now restricted on both sides: the FIRST mock response only, and a control
exercising the SAME operation -- a body that decodes into a different model
says nothing about whether this one still decodes.

* Move $comment out of the assertion properties map, and metaschema-check first

Codex P2: the errorRaised annotation sat INSIDE
properties.assertions.items.properties, so it declared a property literally
named "$comment" whose schema was a string. Draft 2020-12 requires every value
under `properties` to be a schema object or boolean, so conformance/schema.json
was not itself a valid schema -- and tests.schema.json references it, meaning a
validator that meta-validates would reject the whole conformance schema before
looking at a single fixture. Moved alongside `properties`, where JSON Schema
puts annotations.

The reason this shipped is that nothing checked it: the fixture pass validates
fixtures AGAINST the schema and never validates the schema itself, so an
invalid schema sails through. conformance-fixtures-check now runs
--check-metaschema over schema.json and tests.schema.json FIRST, because
validating fixtures against a schema that is not a valid schema proves nothing.

Red proof, through the make target rather than the bare validator: putting the
annotation back inside properties fails at REAL_EXIT=2 with

  conformance/schema.json::$.properties.assertions.items.properties['$comment']:
    '...' is not of type 'object', 'boolean'

* Require a kill case to deliver its malformed value in a 2xx response

Codex P2, the residual hole in the control gate: it compared operation and
body but not the response OUTCOME. Change an errorRaised case's first mock
response from 200 to 500, or to networkError while keeping its body, and the
SDK fails on the HTTP or transport error instead. errorRaised is satisfied by
that failure, requestCount / requestMethod / requestPath all stay green, and
the malformed field is never decoded -- the case goes green having tested
nothing, and body equality cannot see it.

A kill case's premise is that the malformed value arrived in a SUCCESSFUL API
response. That is what makes it the SDK's problem rather than the server's,
and it is why #576 classifies the refusal as a statusless api_error rather
than a transport or HTTP failure. So require it: the first mock response must
carry a 2xx status and no networkError.

Both failure modes proved red, each naming what it would have cost:

  status 500  -> "...so the call fails on the HTTP error before the body is
                 decoded" (REAL_EXIT=1)
  networkError -> "...so the call fails in transport and the body is never
                 decoded" (REAL_EXIT=1)

* Require the control response to reach its decoder too

Codex P2, the symmetric half of the previous commit: not_a_success was applied
to the kill response but not to the control. A control earns its keep only by
being DECODED -- that is what makes it fail loudly (#555) on model drift, which
is the entire protection the kill case borrows from it. A sibling answering 500
or networkError with an object body never reaches its decoder, so it can sit
green on its own HTTP/transport assertions while the drift it was supposed to
catch goes unnoticed in both bodies. Same check now, on both sides.

Red proof needed a second attempt, which is worth recording. Breaking a single
control (update-preserves-due-on -> 500) did NOT fail the gate: cards_write.json
has four non-errorRaised UpdateCard cases, and the gate correctly fell back to
update-explicit-clear, whose body also matches on the same key set differing
only in due_on. That first proof was vacuous -- it demonstrated the fallback
working, not the check.

With all four UpdateCard controls answering 500 the two versions split cleanly:
the pre-fix gate reports `ok` at REAL_EXIT=0, the post-fix gate fails all three
Cards kill cases at REAL_EXIT=1, naming the missing SUCCESSFUL (2xx) control.

* Reject a kill case whose 2xx never reaches a decoder, and self-test the gate

The control-sibling gate accepted any 2xx on both sides, which let a 204
through. A 204 is short-circuited before any parse — TypeScript returns
`undefined`, Kotlin returns `Unit` without calling `parse`, Go rewrites the
body to JSON `null` — so a kill case answering 204 never decodes its malformed
field. The composite fails because the record came back absent, `errorRaised`,
`requestCount: 1`, `requestMethod` and `requestPath` all still hold, and the
control, still a 200, stays green. The gate printed `ok` and exited 0 for
exactly that input: the same false green this gate exists to prevent, one
layer down.

Statuses are now an allowlist, {200, 201}, rather than a 204 exclusion. Two
constraints meet there: Go's success arm is exactly {200, 201, 204}, so 202,
203, 205 and 206 are never decoded there at all; and 204/205 carry no body by
definition. Closed-by-default, because a gate whose whole job is to prove a
body is decoded cannot prove that for a status nobody has reasoned about.
`not_a_success` is renamed `not_decoded` — a 204 does not fail the call, it
bypasses the decode, and the old name said the wrong thing.

Every rejection this gate makes was, until now, correct by inspection alone,
which is the standard that let #576 through five review passes. So it gets a
self-test: `conformance/test_check_kill_case_controls.py` crafts one input per
claimed rejection and asserts the gate refuses it, driven through the real
entry point via a new optional FIXTURE_DIR argument, with the real fixture set
run as a positive control. Reverting only the two new status branches turns
exactly four cases red — 204, 205, an undecoded 2xx, and the control-side 204 —
and nothing else, so the suite is measured non-vacuous rather than assumed so.

It runs inside `make conformance-fixtures-check`, which CI already invokes.

* Document errorRaised in SPEC.md §19's gated assertion table

#590 landed `make doc-constants-check` on main after this branch was cut, and
it gates SPEC.md §19's table against the `conformance/schema.json` assertion
enum: a new type cannot ship undocumented. This branch adds `errorRaised` to
that enum, so the rebase inherited the obligation and Spec Gates went red with
"defines 22 assertion types, the table documents 21".

The row says what the type is for and what declaring it costs — it switches
the stop-on-mismatch policy off for that case, which is why every fixture
declaring it needs a control sibling.

The other finding in that same red run — SPEC.md §Documents restating the
current pin — was #601's, not this branch's, and #605 has since fixed it on
main. An earlier revision of this branch carried its own fix for it; that is
dropped, so the only line this PR adds to SPEC.md is the one above.
jeremy added a commit that referenced this pull request Aug 4, 2026
Fourth review round on #642. Four findings, all upheld.

The allowlist framing was wrong in the direction that matters. I wrote that
fewer hook events are safe for an allowlist. True only if the allowlist named
both operations: one that names UpdateCard and deliberately omits GetCard used
to reject cards.update at its read, and after the collapse permits it end to
end. Both policy shapes now carry the warning, labelled, plus the observation
that they are the same hole seen twice — in each, the thing stopping the write
was the read, expressed once as an omission and once as an entry.

The class-A counting was inconsistent across all six SDKs, not the two flagged.
Python and Kotlin excluded changes their own prose called "no signal
whatsoever"; auditing every SDK against the definition moved the totals to 47
class A and 4 class B. The counting policy is now stated in the document so it
can be checked against a rule rather than an impression: one entry per distinct
change per SDK, counted where it bites; class A if any ordinary call-site shape
stays silent even when another is compile-caught; second faces annotated as
residue and counted once; raises-only-on-malformed-response is class B.

Two things fell out that were not counting problems. Ruby's #563 was missing
from the guide entirely — no mention of download_url anywhere in the chapter —
verified against source rather than prose: v0.12.0 http.get_no_retry, which
sent Accept: application/json and did not retry, became get_download calling
request_with_retry with retry_on: DOWNLOAD_RETRY_ON and accept: nil. Ruby now
has its own section. The same check confirmed Go's omission of #563 is correct,
because Go already retried at v0.12.0. Separately, the Go note claiming the
compiler catches only the pkg/generated half of Schedules().UpdateEntry was
false: UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp
call site that set a field fails to build.

The class-B definition described only half its own membership. It said the
trigger is an absent field, but Ruby's entry fires only when the field is
populated. It now says both, and says plainly that class B is a property of a
call plus a response rather than of the call — the same method against the
other shape is not a break at all. Class A has no such dependency.

Stale counts in the chapter intros are fixed. The Go intro still said eleven
silent and two panics, which is the first thing a #go link shows, and Swift
claimed the most no-signal breaks, which stopped being true at Go ten.

Also folds in #652 (projected-example gate, stacked on #648, takes check-targets
to 43), moves #648 out of draft at cb438ce, and records that #647 is being
reworked Smithy-first because the generated UpdateCardStepRequestContent.DueOn
is *types.Date and cannot express "". The consumer-facing card shape is
unaffected by that rework. Re-derived against #648: 238 -> 247 with 14 added,
5 removed and 11 same-ID route moves survives unchanged.
jeremy added a commit that referenced this pull request Aug 4, 2026
Fourth review round on #642. Four findings, all upheld.

The allowlist framing was wrong in the direction that matters. I wrote that
fewer hook events are safe for an allowlist. True only if the allowlist named
both operations: one that names UpdateCard and deliberately omits GetCard used
to reject cards.update at its read, and after the collapse permits it end to
end. Both policy shapes now carry the warning, labelled, plus the observation
that they are the same hole seen twice — in each, the thing stopping the write
was the read, expressed once as an omission and once as an entry.

The class-A counting was inconsistent across all six SDKs, not the two flagged.
Python and Kotlin excluded changes their own prose called "no signal
whatsoever"; auditing every SDK against the definition moved the totals to 47
class A and 4 class B. The counting policy is now stated in the document so it
can be checked against a rule rather than an impression: one entry per distinct
change per SDK, counted where it bites; class A if any ordinary call-site shape
stays silent even when another is compile-caught; second faces annotated as
residue and counted once; raises-only-on-malformed-response is class B.

Two things fell out that were not counting problems. Ruby's #563 was missing
from the guide entirely — no mention of download_url anywhere in the chapter —
verified against source rather than prose: v0.12.0 http.get_no_retry, which
sent Accept: application/json and did not retry, became get_download calling
request_with_retry with retry_on: DOWNLOAD_RETRY_ON and accept: nil. Ruby now
has its own section. The same check confirmed Go's omission of #563 is correct,
because Go already retried at v0.12.0. Separately, the Go note claiming the
compiler catches only the pkg/generated half of Schedules().UpdateEntry was
false: UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp
call site that set a field fails to build.

The class-B definition described only half its own membership. It said the
trigger is an absent field, but Ruby's entry fires only when the field is
populated. It now says both, and says plainly that class B is a property of a
call plus a response rather than of the call — the same method against the
other shape is not a break at all. Class A has no such dependency.

Stale counts in the chapter intros are fixed. The Go intro still said eleven
silent and two panics, which is the first thing a #go link shows, and Swift
claimed the most no-signal breaks, which stopped being true at Go ten.

Also folds in #652 (projected-example gate, stacked on #648, takes check-targets
to 43), moves #648 out of draft at cb438ce, and records that #647 is being
reworked Smithy-first because the generated UpdateCardStepRequestContent.DueOn
is *types.Date and cannot express "". The consumer-facing card shape is
unaffected by that rework. Re-derived against #648: 238 -> 247 with 14 added,
5 removed and 11 same-ID route moves survives unchanged.
jeremy added a commit that referenced this pull request Aug 4, 2026
* MIGRATING.md: the v0.13.0 upgrade guide, silent breaks first

v0.13.0 breaks all six SDKs and 35 of those breaks are silent — no compile
error, no exception, no decoder failure. Label-generated release notes list
what merged; they cannot say what a consumer must react to or what wrong
behaviour they get if they ignore it. That had no home in this repo.

Adds MIGRATING.md at the root, linked from the root README and all six
per-SDK READMEs. Silent breaks lead the document, then one section per SDK
ordered by severity, plus an operator checklist, a "coverage: corrected and
re-scoped" section for what did not ship, and known gaps.

No CHANGELOG is reintroduced. The hand-maintained ones were deleted in #115
as superseded by auto-generated notes, and every release body since is
machine-built. CONTRIBUTING records the resulting rule: label-generated notes
say what merged, MIGRATING says what to do about it.

Corrections to the source drafts, each re-derived rather than repeated:

- TrashTodo was not a 404. bc3 draws `resources :todos, only: %i[show edit
  update destroy]`; DELETE /todos/:id returned 204 and set status to
  "archived", so every caller was archiving. It is the one #619 removal that
  takes away a working call, and it now carries its own carve-out.
- #619 removed three operations, not nine. Nine were re-pathed. Fusing the
  two sets is what made the blanket 404 reassurance look safe.
- Hook operation identity differs by SDK: Go and Ruby emit a short verb,
  the other four emit the wire operation ID, where the todolist pair kept
  its names — so an allowlist holding UpdateTodolistOrGroup passes the write
  and denies the new read.
- 238 -> 241 measured at the v0.12.0 tag and at c95d81c, not assumed.
- Kotlin binary compatibility is already disclaimed in kotlin/README.md;
  Swift has no written policy. Both are now stated rather than left unsaid.

recordings.get is documented as a known gap with a list-and-filter recipe
and its honest cost. The Go recipe compiles against this tree.

#637, #629 and #635/#641 were open at the time of writing and are recorded
under "Not in this release" rather than described as shipped.

* Fix the Go pagination advice, cut the raw-wire workaround, absorb #637/#643

Addresses both P1 review threads on #642 and folds in the two PRs that landed
since the first draft.

Pagination (P1). Cross-SDK item 1 claimed `page` was a starting offset in every
SDK and told readers to drop it to restore the old walk. For Go that was
actively harmful: `git show v0.12.0:go/pkg/basecamp/bookmarks.go` returns before
followPagination whenever page > 0, so a positive Page already meant one
request, and dropping it converts a bounded call into a full account-wide
traversal. The item is now scoped to the five SDKs where it holds — re-checked
at the tag rather than assumed, since the universal claim had already failed
once — with a Go subsection splitting the two real cases: services where the
page number was already honored (Bookmarks, Drafts, Everything*, request
unchanged) and the fourteen carrying the "not yet honored" doc, which sent no
page at all and returned page 1's rows. Gauges is in neither; it had no page.

Raw wire (P1). The Forwards().CreateReply example built a path with fmt.Sprintf
and called the raw AccountClient.Post against a route with no upstream
coverage, which is what AGENTS.md "Never Do These" 4 and 5 forbid. Removed
rather than softened, and replaced with a known-gap section stating what a
hand-built path gives up. Swept the document: the one other hit documents a real
change to the raw client's error codes, so it stays, but its fabricated path is
gone and it now says it is not a suggestion to reach for the escape hatch.

#643 landed, so basecamp.Ptr and basecamp.Deref replace the hand-rolled ptr
helper throughout, the Go section opens with the 300-pointer census and a
command that reproduces it, and ParticipantIDs *[]int64 gets its own note: nil
leaves participants alone, a pointer to an empty slice removes every one.

#637 landed and does NOT add a break to any SDK. color and comments_app_url did
not exist on Todolist at v0.12.0 in any of the six — both arrived with #628
earlier in this same release — so from the guide's baseline nothing turned from
optional to required. Counts stay 27/20/16/14/16/14. Documented where it bites:
color is required-and-nullable so explicit null decodes, comments_app_url
rejects null and absence alike.

Also: kotlin/README's append-only source-compat promise contradicted this
release repeatedly, so it now describes documented pre-1.0 breaking correctness
releases; the binary-compat disclaimer is kept and sharpened. release-github.yml
links MIGRATING.md from every release body, guarded on the file, so the link
cannot be forgotten at tag time. "Silent" is defined as source/runtime-silent
against a live server, since a suite pinning request paths does catch some.

Counts are stated as-of 51d0d86 with derivations inline, and each in-flight
change names the numbers it invalidates so the pre-tag pass is arithmetic.

* Split silent breaks into no-signal and fails-at-runtime; absorb #629 and cards

Addresses the remaining P2 and a suppressed Copilot comment on #642, re-derives
every count against main, and writes the cards due-date change.

The P2 was right, and it was a contradiction with this guide's own definition
rather than loose wording: "silent" was defined as "does not raise" and then
used to file nil-pointer panics. The section is now "Breaks your compiler will
not catch" — the property all of it actually shares — split into class A, no
signal at all, and class B, compiles then panics or raises but only when a
particular field is absent, so it passes every test where that field is
populated. Applying the definition consistently moved four entries, not the
three flagged: the three Go pointerization panics plus Ruby's
Draft#scheduled_posting_at decode, which raises NoMethodError and TypeError and
had the same defect. Two moved entries carry real no-signal residue, kept as
sub-notes rather than double-counted. Per SDK: Go 8A/3B, Swift 9A, TypeScript
5A, Python 4A, Ruby 2A/1B, Kotlin 3A — 31 + 4 = 35, unchanged in total. Body
counts verified against the table by parsing the section, not by eye.

The Swift section claimed three new optional Todolist members and named one;
the other two are required. Now singular, matching TypeScript.

Counts re-derived at 9de44b2: the inventory is 238 -> 247, not 241, since
#629 merged. Added, removed and route-moved lists are computed from openapi.json
at both ends rather than hand-edited — 14 IDs added, 5 removed, 11 same-ID moves
— and the Folders operations are flagged as drawn at /stacks, not /folders.

Cards get their own section. The half that matters most is true in production
today and is not caused by upgrading: every released SDK encodes "clear a card
due date" as omission, bc3 stopped treating omission as a clear, so that call is
a silent no-op right now. That is a reason to upgrade rather than a hazard of
it, so it sits in the operator checklist. The SDK-side change is read from
bf43715 and marked unmerged: single PUT, "due_on": "" as the clear encoding,
UpdateStepRequest.DueOn becomes *string, and the GetCard preservation read goes
away. The hook collapse is written as the inverse of the {Todolists,Update}
split because it fails the opposite way — allowlists do not start denying, but a
denylist on {Cards,Get} silently stops blocking the write it used to take down.
Removing the preservation GET also removes three named errorRaised kill cases
from cards_write.json; the class stays pinned on Todos, which still does a real
read-modify-write, so that is said rather than filed as a redundant-GET cleanup.

* Audit class A across all six SDKs; add Ruby's missing download retry

Fourth review round on #642. Four findings, all upheld.

The allowlist framing was wrong in the direction that matters. I wrote that
fewer hook events are safe for an allowlist. True only if the allowlist named
both operations: one that names UpdateCard and deliberately omits GetCard used
to reject cards.update at its read, and after the collapse permits it end to
end. Both policy shapes now carry the warning, labelled, plus the observation
that they are the same hole seen twice — in each, the thing stopping the write
was the read, expressed once as an omission and once as an entry.

The class-A counting was inconsistent across all six SDKs, not the two flagged.
Python and Kotlin excluded changes their own prose called "no signal
whatsoever"; auditing every SDK against the definition moved the totals to 47
class A and 4 class B. The counting policy is now stated in the document so it
can be checked against a rule rather than an impression: one entry per distinct
change per SDK, counted where it bites; class A if any ordinary call-site shape
stays silent even when another is compile-caught; second faces annotated as
residue and counted once; raises-only-on-malformed-response is class B.

Two things fell out that were not counting problems. Ruby's #563 was missing
from the guide entirely — no mention of download_url anywhere in the chapter —
verified against source rather than prose: v0.12.0 http.get_no_retry, which
sent Accept: application/json and did not retry, became get_download calling
request_with_retry with retry_on: DOWNLOAD_RETRY_ON and accept: nil. Ruby now
has its own section. The same check confirmed Go's omission of #563 is correct,
because Go already retried at v0.12.0. Separately, the Go note claiming the
compiler catches only the pkg/generated half of Schedules().UpdateEntry was
false: UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp
call site that set a field fails to build.

The class-B definition described only half its own membership. It said the
trigger is an absent field, but Ruby's entry fires only when the field is
populated. It now says both, and says plainly that class B is a property of a
call plus a response rather than of the call — the same method against the
other shape is not a break at all. Class A has no such dependency.

Stale counts in the chapter intros are fixed. The Go intro still said eleven
silent and two panics, which is the first thing a #go link shows, and Swift
claimed the most no-signal breaks, which stopped being true at Go ten.

Also folds in #652 (projected-example gate, stacked on #648, takes check-targets
to 43), moves #648 out of draft at cb438ce, and records that #647 is being
reworked Smithy-first because the generated UpdateCardStepRequestContent.DueOn
is *types.Date and cannot express "". The consumer-facing card shape is
unaffected by that rework. Re-derived against #648: 238 -> 247 with 14 added,
5 removed and 11 same-ID route moves survives unchanged.

* Correct four claims in the v0.13.0 guide that do not match the source

The opening warning said the runtime failures need a payload where a field is
absent. That holds for the three Go entries; Ruby's single class-B entry has the
opposite trigger. Draft#scheduled_posting_at and MyNote#created_at/#updated_at
run through parse_datetime, which returns nil for nil and a Time otherwise, so
.start_with? and Time.parse raise only when the field is populated. A reader
following the old text builds the wrong fixture and concludes they are
unaffected. Both directions are now named, here and in the root README.

Class A was described as breaking on every response. Most of it does, but two
groups do not: the error-message and validation entries need an error status to
reach the code at all, and the field-map half needs a body of a particular
shape; downloadURL's hop-1 retry changes nothing until a network error or one of
429/502/503/504 occurs. Stated as preconditions rather than as a blanket claim.

The Go pointer example said only the field selector panics. types.Date.String
has a value receiver, so Go rewrites t.DueOn.String() to (*t.DueOn).String() and
the nil dereference panics before String is entered. The same holds for IsZero,
Before, After and Weekday on Date and for Format, Sub, Unix and Year on
time.Time. The summary bullet already said both panic; the example contradicted
it.

The Accept-header note credited only Python. Ruby dropped it on the same hop:
get_download passes accept: nil, and request_headers sets the header only when
accept is truthy. Both are named, with the observation that the other four never
sent it on that hop at v0.12.0 either.

No counts are touched.

* Re-derive every count against the final release commit

Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged
since the branch was last updated, not the seven that carried the breaking
label: #647 was on the "Not in this release" list and had landed.

Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51.
Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0,
Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17.
The body parses back to those numbers rather than agreeing with them by hand.
The root README's aggregate sentence is re-derived to match, and now states both
halves numerically instead of "most" and "a few". The operation inventory is
unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route
moves, computed from openapi.json at both ends. check-targets is 43, and the
derivation is inline where the gate count was previously only projected. The
release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce
both are embedded in the as-of block, with the note that a labelled PR is not
the same unit as an entry, which is why the per-SDK columns exceed 15.

#658 is class B, not class A. It does to five wrapper timestamps exactly what
#615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's
CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt
compile untouched through a value-receiver call and panic on nil. #615's own
check could not see them because it keyed on the omitempty tag and these five
did not carry one. The audit is ten fields, and the entry names the near-miss
siblings that did not move, ClientApproval's pair in particular.

#664 splits. The public CreateScheduleEntryRequest fields were already string
and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone,
a bare date now creates an all-day entry, and a malformed value reaches bc3
instead of failing locally. That is class A. The generated
CreateScheduleEntryRequestContent went time.Time to string, which is a compile
error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a
migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are
doc-comment only.

#647 is folded in as merged, with two corrections to what was written when it
was still a branch. It touches no schema, so the claim that it had to go
Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by
#560. And the v0.12.0 preservation GET was conditional, taken only when the
caller left due_on unaddressed, so the request-count table is scoped to that
path rather than presented as universal.

#648 adds no silent break anywhere. bc3's body is byte-identical before and
after, so nothing that was populated stops being so; the assignable's title was
never sent and is now spelled content. Every rename and retype is caught
statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python
and Ruby, so it is one compile-or-runtime entry per SDK.

Two corrections nobody asked for. The Go class list opened "Go carries every
class-B break in the release", which stopped being true when Ruby's decode
entry moved into class B; it now claims only the panic-shaped ones. And
todos_write.json carries three errorRaised cases, not two, because #660 added a
bare-scalar kill.

#660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient
means a present, populated, wrong-typed scalar throws SerializationException
where it used to coerce to a string, and no signature moved to announce it. It
throws in the response decode, so on a write the mutation has already landed,
and it is not a BasecampException outside todolists.

#656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET,
which means get_absolute and the Launchpad fetch rather than any operation
lacking a policy. Every other configuration is bit-identical.

Not in this release is now empty, and says so.

* State the schedule-entry clear value per field instead of universally

The Swift Behavioural bullet said an explicit "" clears any of the five
full-state fields. Only description does. "" on summary is accepted and
reads back "Untitled"; starts_at and ends_at are under
validates_presence_of in Schedule::Entry, so "" is rejected rather than
cleared; allDay is a boolean in every SDK, so "" does not typecheck at
all. The carve-out half grouped notify with the three clearable fields
even though it is a send directive with no state to clear.

* Re-derive the per-SDK README banners against the final class A/B table

The six SDK README banners still carried the counts from before the Go
reclassification and the recount that followed it, summing to 51 where
MIGRATING.md and the root README say 61. Each banner now matches its row
in the class A/B table: Go 12+4, Swift 10, TypeScript 9, Python 8, Ruby
10+1, Kotlin 6+1. Kotlin also gains the runtime clause it was missing,
since its one class B entry throws on a present field carrying a JSON
number or boolean where the model declares a string.

* Correct the merged-PR count and the two claims the reviewers caught

The release spans 55 merged pull requests, not 67. The 67 came from comparing
GitHub's Z-formatted mergedAt against a git timestamp formatted with a local
offset, using jq's string >, which is lexicographic rather than temporal; it
wrongly swept in twelve PRs merged in the hours before the v0.12.0 tag instant.
The derivation embedded in the guide taught that same broken comparison, so it
now uses %ct and fromdateiso8601 and says why. The breaking count of fifteen is
unchanged, since all fifteen merged after the tag, so the class A/B split, the
per-SDK tables and the six README banners are untouched.

The header no longer calls 2afc977 the commit the release is cut from. That
commit is the last of the release content and the baseline the counts were
measured against, but it predates this guide; the tag is cut from main after
this merges, on a tree that contains the file the release body links to.

The release-body teaser claimed the guide covers only breaks with no exception
and no decoder failure. The guide documents six breaks that do fail at runtime,
including Ruby and Kotlin raises and a Kotlin decoder failure, so the teaser now
names both the silent class and the runtime one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conformance Conformance test suite enhancement New feature or request go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK swift typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants