Skip to content

docs(sdk): contract 1.8 — retry policy, decision memo, close(), telemetry (D5) - #283

Merged
ilpanich merged 1 commit into
mainfrom
claude/improvements-run5-benchmark-def-bazzei
Aug 9, 2026
Merged

docs(sdk): contract 1.8 — retry policy, decision memo, close(), telemetry (D5)#283
ilpanich merged 1 commit into
mainfrom
claude/improvements-run5-benchmark-def-bazzei

Conversation

@ilpanich

@ilpanich ilpanich commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Adds the four SDK quality-of-life sections D5 calls for — and fixes a dangling normative reference found while writing the first of them.

Docs only: one file, sdks/CONTRACT.md, +344/−7. No code, no OpenAPI, no proto. This is the contract half of D5; the eleven-repo implementation fan-out follows under D6's recurring gate.

The defect this uncovered

§11.2 rule 5 and §14.2 rule 6 have both been instructing SDKs to retry "under the SDK's existing bounded read-only retry policy" — and no such policy was ever defined in this contract.

I surveyed all eleven repos before writing a line of it:

SDK Read-only retry policy today
Java 3 attempts, 200 ms base, 5 s cap, full jitter, honors Retry-After
Rust 3 attempts via backon defaults, NetworkError only, no jitter, no Retry-After
The other nine None. Only §9's refresh-then-retry-once, which is a different mechanism

So two normative clauses were deferring to eleven separate guesses, and the two SDKs that did implement something disagreed with each other. Same defect class as the §14.3 device_login errata in 1.7, but this one was live in two sections at once.

§16 is that missing policy as one binding table. The values follow Java's, which was closest to what the phrase implied.

What the four sections settle

§16 Retry Policy — MUST

  • Eligibility is "changes no server state", explicitly not "is an HTTP GET." AXIAM's authorization check is a POST with a request body and is the single most important operation in the section. An SDK that gates retry on the HTTP verb retries nothing that matters. Called out in bold because it is the mistake this section exists to prevent.
  • Full jitter, specified as a range and tested as one. Not "backoff ± 10%". Partial jitter keeps every client's retries clustered around the same instant, which causes the failure mode retries are meant to fix. The required test pins the PRNG to 0 and to 1 to prove the range really is [0, backoff].
  • Retry-After is a floor, never a ceiling — so a Retry-After: 0 cannot defeat the backoff.
  • The ineligible list is justified twice over: these operations change state (a transient failure after the server committed is indistinguishable at the client from one before it committed), and their credentials are single-use (an authorization code, a device code at redemption, a rotating refresh token) so a retry replays a spent credential and turns a recoverable blip into a hard invalid_grant.
  • It does not amend §9. The two compose in one direction only: an operation inside a §9 refresh-retry may itself be §16-retried, but a §9 refresh may not be, and a mid-operation refresh does not reset the §16 budget.
  • Revocation gets its own note: §12.1's RFC 7009 idempotency remark is about server behaviour and is not licence for a client to retry a mutation.

§17 Client-Side Decision Memo — MAY, off by default

§11.2 rule 6's ban on decision caching stands as the default; §17 is the single opt-in exception, and the rule is amended to say exactly that rather than being quietly contradicted.

  • Mirrors the server's own DECISION_CACHE_TTL_SECS / SESSION_VALIDATION_CACHE_TTL_SECS bound rather than inventing a second staleness story.
  • TTL clamped to 5 s. The server's equivalent setting is an unclamped u64 that lets an operator configure a multi-hour staleness window — a known residual. The client has no reason to repeat it, and the contract says so.
  • Allows and denies cache identically. Asymmetric caching changes the timing of the two outcomes and so leaks which one occurred to anyone who can observe latency.
  • Read-your-own-writes is not guaranteed, in those words. The admin UI that grants a role and immediately re-checks is the case that breaks, and it breaks silently.
  • Rule 10: the memo must never serve §11.2 rule 5's fail-closed path. Serving a stale allow during an outage inverts fail-closed into fail-open at exactly the moment it matters.
  • Never negative-caches a failure — caching a transport error as a deny would turn a blip into a TTL-long outage.

§18 Deterministic Shutdown — MUST

Each language's existing idiom, not a new invented spelling. Idempotent; releases everything; use-after-close raises rather than being undefined behaviour in the manual-memory languages.

Close must not reach the network. An SDK that logged out on close would silently end sessions on every deploy. The required test asserts against the transport rather than the return value, because that is what catches a logout accidentally wired in.

§19 Telemetry Hooks — SHOULD

Optional callbacks so callers can wire OpenTelemetry or Prometheus without this SDK depending on either; the OTel adapter ships as an examples/ entry per SDK.

  • A hook that throws is caught and swallowed. Telemetry is not permitted to fail an authorization check.
  • Payloads never carry tokens, bodies, or Sensitive<T> contents — this surface exists to be shipped to a metrics backend, which is the last place a bearer token should land. The required test scans the serialized payload for the fixture token, the same discipline §12/§14/§15 use on error paths.
  • Path templates, not substituted URLs, so a metric label cannot become a cardinality bomb.
  • One request_start/request_end pair per attempt, not per logical call, so a caller can count real wire calls. §16.5 requires the retry event for the same reason: a retried-then-succeeded operation is otherwise invisible, and that silence is the standing objection to automatic retry.

Conformance — stated plainly, not papered over

§16 and §18 are MUST-level and land unimplemented everywhere. §18 exists today only in the TypeScript and Python gRPC clients and C's axiam_client_free; §16 exists in two disagreeing forms.

The Closing Notes say so directly: each SDK is non-conformant on those two sections until the D6 fan-out lands them, and its README must not imply otherwise. They are not named in the conformance statement, because a MUST is not something an SDK opts into. §17 and §19 are optional and are named when shipped, like §14/§15.

Verification

Gate Result
scripts/check-doc-links.sh 129 relative links resolved across 21 files
scripts/check-remediation-evidence.py 37 verified, 0 failed, 0 skipped, 0 unparseable
scripts/check-frontend-coverage.py 30 handler modules, all recorded
docs/api/openapi.json parses ok

New internal anchors follow the file's existing convention and resolve under the link-checker.

Notes


Generated by Claude Code

…etry (D5)

Adds the four SDK quality-of-life sections D5 calls for, and fixes a
dangling normative reference found while writing the first of them.

§16 Retry Policy (MUST). §11.2 rule 5 and §14.2 rule 6 have both been
instructing SDKs to retry "under the SDK's existing bounded read-only retry
policy" — a policy that was never defined anywhere in this contract. Surveying
the eleven repos: Java had one (3 attempts, 200 ms base, 5 s cap, full jitter,
Retry-After honored), Rust had a different one (3 attempts, library-default
backoff, no jitter, no Retry-After), and the other nine had none at all — only
§9's refresh-then-retry-once, which is a different mechanism. So two normative
clauses were deferring to eleven separate guesses. §16 is that missing policy,
as one binding table.

The values follow Java's, which was the closest to what the phrase implied.
Full jitter is specified explicitly and tested for its full [0, backoff] range,
because partial jitter keeps every client's retries clustered and so causes the
failure mode retries are meant to fix. Retry-After is a floor, never a ceiling,
so Retry-After: 0 cannot defeat the backoff.

Two things §16 is careful about:

- Eligibility is "changes no server state", NOT "is an HTTP GET". AXIAM's
  authz check is a POST with a body and is the single most important operation
  in the section; an SDK gating on the verb retries nothing that matters. The
  ineligible list is justified twice over — state change, and single-use
  credentials that a retry replays into a hard invalid_grant.
- It does not amend §9. The two compose in one direction only: an operation
  inside a §9 refresh-retry may itself be §16-retried, but a §9 refresh may not
  be, and a mid-operation refresh does not reset the §16 budget.

§17 Client-Side Decision Memo (MAY, off by default). §11.2 rule 6's ban on
decision caching stands as the default; §17 is the single opt-in exception,
mirroring the server's own decision-cache and session-validation-cache bound
rather than inventing a second staleness story. Unlike the server's equivalent
setting — an unclamped u64 that lets an operator configure a multi-hour
staleness window — the client TTL is clamped to 5 s. Allows and denies cache
identically, because asymmetric caching leaks the outcome through latency.
Read-your-own-writes is explicitly not guaranteed, in those words, because the
admin UI that grants a role and immediately re-checks is the case that breaks
and it breaks silently. Rule 10 forbids the memo from serving §11.2 rule 5's
fail-closed path, which would invert fail-closed into fail-open exactly when it
matters.

§18 Deterministic Shutdown (MUST). Every SDK exposes shutdown in its own
language's existing idiom rather than a new invented spelling. Idempotent,
releases everything, use-after-close raises rather than being undefined. Close
MUST NOT reach the network: an SDK that logged out on close would silently end
sessions on every deploy, and the required tests assert against the transport
to catch exactly that.

§19 Telemetry Hooks (SHOULD). Optional callbacks so callers can wire metrics
without this SDK depending on OpenTelemetry; the OTel adapter is an examples/
entry per SDK. A hook that throws is swallowed — telemetry may not fail an
authorization check. Payloads carry a fixed field list and never tokens,
bodies, or Sensitive<T> contents, since this surface exists to be shipped to a
metrics backend. Path templates, not substituted URLs, so a metric label cannot
become a cardinality bomb. One request_start/request_end pair per attempt, not
per logical call, so a caller can count real wire calls — and §16.5 requires
the retry event precisely because a retried-then-succeeded operation is
otherwise invisible, which is the standing objection to automatic retry.

Conformance. §16 and §18 are MUST and land unimplemented everywhere (§18
existed only in the TS/Python gRPC clients and C's axiam_client_free). The
Closing Notes say so plainly: each SDK is non-conformant on those two until the
D6 fan-out lands them, and its README must not imply otherwise. §17 and §19 are
optional and named in the statement when shipped, like §14/§15.

Contract 1.6 → 1.7 was the device_login errata; this is 1.7 → 1.8. Docs only —
no code, no OpenAPI, no proto. Verified: check-doc-links (129 links, 21 files),
check-remediation-evidence (37 verified, 0 failed), check-frontend-coverage,
and the OpenAPI parse gate all pass.
@ilpanich
ilpanich merged commit de2eaae into main Aug 9, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants