Skip to content

[FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path - #434

Open
justin13888 wants to merge 7 commits into
chore/freeze-capsule-core-api-399from
fix/sdk-escrow-route-and-401-retry-408
Open

[FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path#434
justin13888 wants to merge 7 commits into
chore/freeze-capsule-core-api-399from
fix/sdk-escrow-route-and-401-retry-408

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

capsule-sdk's recovery client called a route the contract does not serve, and three
smaller gaps rode along with it. This lands the route fix on the transport AGENTS.md
mandates, the reactive 401 layer S-D17 asks for, a client for the fourth
application/cbor operation, and truthful crate docs.

Summary

The defect. capsule-sdk/src/recovery/mod.rs built {api_root}/backup/escrow from a
const and sent it with hand-written reqwest calls. The committed Kynos document serves
GET/PUT /v1/auth/escrow. Every networked recovery flow — enroll, the stale-cache
refresh, the guided re-wrap's escrow replace — failed against a real server while S-D12
read done. It survived because the path was a string constant no gate checks and the
module's own mock answered whichever path it was handed.

The fix is not the constant. Both escrow operations are application/octet-stream in
each direction, a media type spargen lowers, so both are already generated and neither is
narrowed out in build.rs. RecoveryClient now holds one AuthenticatedClient and
orchestrates fetch_escrow/store_escrow; the path is a function of the document and
cannot drift again.

Alongside it:

  • S-D17RefreshOn401, an rest::HttpBackend wrapping ReqwestBackend, installed
    by AuthenticatedClient. Every generated operation now recovers from a 401 with one
    single-flight refresh and exactly one replay. No generated code is touched.
  • capsule_sdk::upgrade — a client for POST /v1/albums/{album_id}/upgrade, the fourth
    application/cbor operation, sending the signed intent verbatim.
  • Doc truthOpenAPI 3.13.2, mise run openapimise run openapi-kynos, the
    hand-written CBOR client count, and the gRPC-sync-feed claims in client.rs, lib.rs,
    auth.rs and ffi.rs (sync.rs still carries four such lines and is outside this lane's
    manifest — see Unresolved review notes).

Both in-repo escrow mocks now route on /v1/auth/escrow and answer 501 elsewhere, so a
route regression fails loudly instead of reading as "no escrow stored" — and the socket test
asserts the route from both ends through the fixture's in-process client rather than relying
on how the router happens to render a 404.

Six commits plus a base merge, each coherent on its own and each revertable alone:

Commit What
1a26565 the escrow route onto the generated client
49d2084 the error classification an adversarial read of the first commit corrected, plus the route assertion
16fb5f5 S-D17RefreshOn401
9143e74 capsule_sdk::upgrade
17f17f6 crate-doc truth and the two slice rows
7c71817 the lane review's three findings: stop minting a catalog code, pin the auth/transport split, correct the transport doc
732967f merge of chore/freeze-capsule-core-api-399 at f508bf1a (clean; no conflicts, no overlapping files)

Validation

Every command below was run inside the worktree on the final head 732967f9 — after the
merge of chore/freeze-capsule-core-api-399 at f508bf1a — and its result observed.
mise run check-rust is reported as its own fifteen steps because the aggregate task was
killed twice by the machine (exit 137/143) under parallel-lane load, never by a check failing;
each step was then run and observed individually.

Command Outcome
cargo nextest run -p capsule-sdk pass — 167/167
cargo nextest run -p capsule-sdk --features ffi pass — 177/177
cargo nextest run -p capsule-server --test sdk_client pass — 7/7
mise run format-check-rust pass
mise run lint-check-rust pass
mise run doc-check-rust pass (the base's widened --document-private-items gate)
mise run i18n-check pass
mise run i18n-guard pass
mise run openapi-check-kynos pass — "OpenAPI document is up to date"; byte-identical, the server is untouched
mise run architecture-check pass
mise run license-check pass
mise run translate-readme-check pass
mise run build-rust pass
mise run build-check-wasm pass
mise run build-ffi pass
mise run lint-check-ffi pass
mise run gen-bindings pass — both uniffi namespaces regenerate and the surface check passes; bindings go to target/bindings and are not committed, so FfiError::Escrow's new code member changes no tracked file
mise run verify-examples pass — 16/16
mise run test-rust pass — workspace 1723/1723, capsule-core --features ffi 729/729, capsule-sdk --features ffi 177/177
mise run check-docs-truth pass — 473 links, 84 endpoint citations, 118 module paths
mise run check-md pass — 0 issues across 168 files

Deliberate negative controls. Three of the new tests were confirmed to fail with the
change backed out, so none passes vacuously:

  • with Client::with_backend reverted to with_client,
    client::tests::a_401_is_refreshed_once_and_the_call_replayed and
    a_token_the_server_stopped_honouring_is_refreshed_and_the_call_replayed both fail with the
    server's 401;
  • with the rest::AuthError downcast arm disabled,
    a_session_that_cannot_mint_a_bearer_is_an_auth_failure fails — the case exists precisely
    because that arm had no test;
  • the escrow socket case fails on the pre-fix route, because store_escrow against
    /backup/escrow answers an undocumented 404.

CI on this branch (last observed at 17f17f64, pre-merge): every required check green
(required SUCCESS), including Rust fmt+clippy+build, Rust tests, all four cross builds,
docs-truth, markdown and commit lint. One job fails — Build Capsule.apk + :core JVM smoke
and it is pre-existing: the same job fails on PR #426, this branch's base, and nothing
here touches Kotlin, Android or Gradle.

Risks and rollout

Client-only. openapi.json, capsule-server/src/** and capsule-core/** are untouched, so
the wire contract is unchanged and openapi-check-kynos stays byte-identical.

  • RecoveryClient::new becoming fallible is a source break inside this crate only
    (capsule-sdk is publish = false); both call sites are in ffi.rs.
  • RecoveryError loses Body and Auth and gains five variants. Nothing outside
    capsule-sdk matches on it.
  • FfiError::Escrow gains a code: Option<String> member, which changes the uniffi
    binding shape
    for Swift and Kotlin (decision 6). The generated bindings are emitted to
    target/bindings and are not committed, so no tracked file changes and mise run gen-bindings passes; the only in-repo consumer matches Escrow { .. } and is unaffected.
    A native app that constructed the variant positionally would need the extra field — none
    does today, and the change is what lets a client tell "set up a recovery key" from "we could
    not read the one you have".
  • AuthenticatedClient's observable behaviour changes: every generated operation now replays
    once on a 401. The operations it serves carry in-memory bodies; the one streamed shape
    rides the hand-written upload.rs, and try_clone() == None short-circuits it regardless.
  • Backout is one git revert per commit; no persisted data and no deployed behaviour.

Related Issues

Closes #408

Filed by this lane for work it deliberately did not do:

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

Decisions taken

Issue 408 - sdk: recovery calls a route the contract does not serve, and three smaller gaps
Plan: v1 (planned against f433d91; executed on the head of lane #399's branch)
Branch: fix/sdk-escrow-route-and-401-retry-408
Base: the head of lane #399's branch (chore/freeze-capsule-core-api-399), stacked; the PR targets that branch until it merges
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-fix-sdk-escrow-route-and-401-retry-408
Cause: capsule-sdk/src/recovery/mod.rs:41 hand-writes a reqwest path to {api_root}/backup/escrow; the contract serves GET/PUT /v1/auth/escrow, and those operations are already generated (octet-stream is a media type spargen lowers). The path survived because S-D28 re-sourced the document from Kynos and nobody re-checked the hand-written client.
Touches: capsule-sdk/src/{recovery/mod.rs,client.rs,auth.rs,upgrade.rs (new),lib.rs,ffi.rs}, capsule-sdk/build.rs, capsule-server/tests/sdk_client.rs (append-only), SLICES.md (rows/blocks S-D12 and S-D17 ONLY)
Will not: touch Cargo.toml default-members (#399 owns it), capsule-server/openapi.json, capsule-server/src/, capsule-sdk/src/sync.rs, capsule-sdk/src/verify.rs, capsule-core/
Lane: serialised behind #399
Settled: capsule-sdk joins default-members — settled by #399. Base branch = head of PR #418 → this lane stacks on #399's head.

Decisions taken.

  1. Deliverable boundary - all four issue bullets in one lane, minus the receipt-decode defect.
    Taken: Ship the escrow fix, S-D17, the upgrade client, the doc corrections and the filed spargen issue as five slices on one branch; the in-process server harness at capsule-server/tests/sdk_client.rs:45-129 already exists.
    Rejected: Also fix capsule-sdk/src/verify.rs:305-313, which decodes GET /v1/upload/{id}/receipt as JSON while the handler returns Binary (capsule-server/src/routes/receipts.rs:91) - not in sdk: recovery calls a route the contract does not serve, and three smaller gaps #408, changes the release-gate path that verify/tests.rs:263-304 mocks as JSON, and needs an attestation-key fixture over a socket: a second issue's worth of test work.
    Reverses: git revert the branch; the receipt defect is untouched either way.
    Filed: the lane files "sdk: fetch_receipt decodes JSON where the contract serves application/cbor".

  2. Escrow transport - move to the generated client rather than fix the path string.
    Taken: Replace recovery/mod.rs's hand-written reqwest calls with AuthenticatedClient::{fetch_escrow, store_escrow}; AGENTS.md requires generated parsing, the generated methods exist, and the route cannot drift again.
    Rejected: Change ESCROW_PATH to "v1/auth/escrow" and keep the reqwest path - keeps a second parser the rule forbids and a route string no gate checks.
    Reverses: restore recovery/mod.rs from the parent commit and set ESCROW_PATH to "v1/auth/escrow".

  3. S-D17 seam - an HttpBackend wrapper, not the Middleware trait.
    Taken: RefreshOn401 as rest::HttpBackend wrapping ReqwestBackend, installed via Client::with_backend in client.rs::build_client, replaying with Request::try_clone() and refreshing through a new pub(crate) Session::refresh_rejected. No generated code touched; every generated operation is covered.
    Rejected: spargen's Middleware trait - Next::run consumes self and Next is not Clone/constructible (rest_client.rs:1250-1275), so a middleware cannot send twice. Also rejected: copying sync.rs:472-499's per-call loop into callers - the duplication S-D17 exists to remove.
    Reverses: revert client.rs to Client::with_client and delete refresh_rejected.

  4. sync.rs's bespoke 401 loop - leave it.
    Taken: SyncConsumer keeps its own refresh-and-retry; it has a static-token mode (SyncAuth::Static, sync.rs:460) with no session to refresh and interleaves 401 with the shared retry engine's transient class.
    Rejected: Route SyncConsumer through AuthenticatedClient - AuthenticatedClient::new takes a Session unconditionally (client.rs:60); adopting it means dropping static-token mode or widening AuthenticatedClient, both larger than this issue.
    Reverses: construct SyncConsumer's client with with_backend and delete the 401 arm of the pull loop once AuthenticatedClient grows a static-token constructor.

Decisions taken inside the manifest, during delivery (same shape; numbering continues the record).

  1. An uncoded 404 on the escrow fetch is a wire failure, never "no escrow".
    Taken: A 404 whose body is not a parseable CodedProblem maps to
    RecoveryError::Transport, never to NotEnrolled. Only the server's own coded
    body proves an unenrolled account; an intermediary's 404 must not read as
    "enroll first". Pinned by an_uncoded_404_is_not_read_as_an_empty_escrow.
    Rejected: Fall back to NotEnrolled on any 404, which is what the hand-written client did.
    Evidence: that reading is precisely the defect class this issue exists to close -
    it let backup/escrow look like an account that had escrowed nothing for a whole
    slice. An intermediary answering 404 text/html is a broken path, not an
    enrollment state.
    Reverses: map a bare 404 to NotEnrolled in wire_error's Decode arm - and reintroduce
    the blindness.

  2. FfiError::Escrow gains a code field.
    Taken: Add code: Option<String>, populated from RecoveryError::error_code(), so Swift
    and Kotlin can switch on error.escrow.* - not_stored ("set up a recovery key")
    against unavailable ("we could not read the one you have"). The enum's own doc
    already claimed every variant carries the catalog code, and Escrow was the one
    that did not. This is a uniffi binding shape change; see Risks and rollout.
    Rejected: Fold the code into the message string. Evidence: foreign clients cannot switch
    on a substring, which is the entire reason the { error, code } contract exists.
    Also rejected: leave it and file an issue - this lane is the one that introduced
    error_code(), and shipping an accessor no boundary can read is a half-done fix.
    Reverses: drop the field and the three code: initializers.

  3. A body-less 413 carries no code - the client never mints one.
    Taken: Both hand-written clients report code: None with an English detail on 413
    (recovery/mod.rs, upgrade.rs). The transport backstop sends no problem body, so
    there is no code; every other code either module reports is the one the server
    stamped, and a code minted on this side asserts the server said something it did
    not. The typed variant already carries the actionable half ("these bytes will not
    do, do not resend them").
    Rejected: Mint error.request.too_large client-side, which is what the first version of
    this branch did. Evidence: a client localizing it would read the SDK's guess as
    the server's judgement, and it is the only code in either module with no server
    behind it. Also rejected: stamp the code server-side in the 413 backstop -
    capsule-server is outside this lane's manifest; recorded here as a note for the
    lane that owns problem.rs/limits.rs.
    Reverses: restore the two REQUEST_TOO_LARGE arms and their assertions.

  4. Manifest widened by one file - capsule-sdk/src/ffi/tests.rs.
    Taken: Repoint the ffi module's own test mock from /api/backup/escrow to
    /api/v1/auth/escrow, and give its PUT a JSON StoreEscrowResponse and its empty
    GET an RFC 9457 body. The record's Touches line names capsule-sdk/src/ffi.rs;
    the ffi module's tests live in the sibling ffi/tests.rs, and the plan did not
    foresee that that mock hardcodes the route. Without it
    ffi::tests::ffi_escrow_and_device_directory_round_trip fails against its own
    mock, so slice 1 cannot be delivered coherently at all. Recorded here rather than
    taken silently, per the manifest rule.
    Rejected: Stop and return "needs re-plan". Evidence: the file is the test submodule of a
    file already in the manifest, the change is a route string and two response bodies
    in a test double, and no other lane touches capsule-sdk/src/ffi/** - freezing a
    lane over a mock's path constant costs the whole deliverable and buys nothing.
    Reverses: revert the ffi/tests.rs hunk; the test then fails, which is the honest signal.

  5. RecoveryError::Auth deleted, and RequestConstruction split by its source.
    Taken: Drop the Auth(#[from] AuthError) variant - once the reqwest path is gone nothing
    can construct it - and instead discriminate spargen's RequestConstruction class
    by downcasting its source to the generated runtime's AuthError, which only the
    bearer provider produces. A dead session therefore still reaches a caller as
    Unauthorized -> FfiError::Auth, preserving the re-auth signal the hand-written
    path gave, while a refused connection stays Transport. Both sides are pinned:
    a_session_that_cannot_mint_a_bearer_is_an_auth_failure and
    an_unreachable_endpoint_is_a_transport_failure_not_an_auth_one.
    Rejected: (a) Keep Auth as an unconstructible variant - a promise no code path can keep, in
    a public error enum. (b) Map the whole RequestConstruction class to Unauthorized
    - the first attempt, refuted by the adversarial read: reqwest builds every failure
    of the request it executes with error::request(..), so is_request() is true for
    connection-refused, DNS and TLS, and the mapping told an offline device to sign in
    again.
    Reverses: restore the variant and map RequestConstruction to Transport unconditionally;
    the cost is that a dead session reads as a network blip at the FFI boundary.

  6. One shared reqwest client for the process.
    Taken: Make client::reqwest_client() a OnceLock returning clones. The FFI's escrow
    verbs build a fresh AuthenticatedClient per call because the API root is a
    per-call argument, and a reqwest::Client owns a connection pool - so before this,
    every escrow read paid a fresh TLS handshake where the old code rode the session's
    shared client. Nothing here is configured per instance, so there is nothing to
    vary. The residual cost is named in the code: Client::with_backend still builds
    its own request-assembly client per construction, which opens no connection.
    Rejected: Cache an AuthenticatedClient on FfiSession. Evidence: the base URL is a
    per-call argument, so the cache needs a key and an eviction rule - larger than the
    regression it fixes, and outside this issue.
    Reverses: inline the builder again; correctness is unaffected either way.

Unresolved review notes

Raised against this diff before committing (a focused sub-agent read the first commit
adversarially) and in the lane review of 17f17f64. Everything actionable was fixed in
49d2084 and 7c71817; what remains is outside the lane manifest and is listed here rather
than widened into silently.

  1. capsule-core/src/lifecycle/backup.rs:29 still documents PUT /backup/escrow. The
    same stale route, in a doc comment. capsule-core/** is explicitly out of this lane's
    manifest (core: freeze the capsule-core public API and remove the dead surface #399 owns that tree), so it is untouched. One-line fix for whoever holds it next.
  2. SLICES.md:3384 lists the surface as GET/PUT /v1/auth/backup/escrow. Also wrong —
    the route is /v1/auth/escrow. That row is neither S-D12 nor S-D17, and this lane may
    edit only those two, so it is untouched.
  3. capsule-sdk/src/sync.rs:213, :258, :430 still describe the feed in gRPC terms
    (:11 names it historically, which is fine). Pre-existing, and sync.rs is named in the
    record's Will not list — decision 4 keeps its bespoke 401 loop — so its prose was left
    alone. The issue's doc-truth bullet named client.rs, which is fixed, along with lib.rs,
    auth.rs and ffi.rs.
  4. The 413 backstop sends no error.* code at all. Decision 7 stops the client from
    inventing one, which leaves the gap where it belongs: on the server. Stamping
    error.request.too_large in capsule-server's body-limit backstop
    (problem.rs/limits.rs) is the real fix, and capsule-server/src/** is outside this
    lane's manifest. Noted for the lane that owns it; no issue filed, per this lane's brief.
  5. Client::with_backend builds one throwaway reqwest::Client per AuthenticatedClient.
    It only assembles requests — execution goes through the shared client below it — so it opens
    no connection and costs one allocation. Removing even that needs a with_client_and_backend
    constructor spargen does not expose. That is generator work of the same kind as the
    application/cbor gap and would land alongside spargen: classify_media has no application/cbor, forcing four hand-written clients in capsule-sdk #440; spargen: classify_media has no application/cbor, forcing four hand-written clients in capsule-sdk #440 was not edited to say so.
  6. RecoveryError, UpgradeError and FfiError are not #[non_exhaustive]. This change
    adds variants and a field to all three. Nothing outside capsule-sdk matches on any of them
    (capsule-sdk is publish = false, and the only in-repo consumer of FfiError::Escrow
    matches { .. }), so nothing breaks — but a future variant would be a source break again.
    Marking them non_exhaustive is itself a break and was not taken unilaterally.

… client

`capsule_sdk::recovery` built `{api_root}/backup/escrow` from a `const` and sent it
with hand-written `reqwest` calls. The committed Kynos document serves
`GET`/`PUT /v1/auth/escrow`, so every networked recovery flow — enroll, the
stale-cache refresh, and the guided re-wrap's escrow replace — failed against a real
server while S-D12 read `done`.

The route is not fixed by editing the constant. Both operations are
`application/octet-stream` in each direction, which is a media type spargen lowers, so
both are already generated and neither is narrowed out in `build.rs`; `AGENTS.md`
requires that everything which parses or serializes is generated, the byte-serving
endpoints included. `RecoveryClient` now holds one `AuthenticatedClient` and
orchestrates `fetch_escrow`/`store_escrow`, so the path is a function of the document
and cannot drift again.

What the move changes:

- `RecoveryClient::new` is fallible (`RecoveryError::InvalidBaseUrl`) — the generated
  client parses its base once at construction rather than per call. The two FFI
  callers each grow a `?`.
- `RecoveryError` drops `Body(reqwest::Error)` and `Auth(AuthError)`, which nothing can
  construct once the reqwest path is gone, and gains `Transport`, `Unauthorized`,
  `Malformed` and `InvalidBaseUrl`, plus an `error_code()` returning the stable
  `error.escrow.*`/`error.auth.*` code a client localizes.
- A refused credential keeps its auth identity across the FFI boundary:
  `Unauthorized` maps to `FfiError::Auth`, where a failed refresh used to arrive as
  `RecoveryError::Auth`. A request-construction failure maps there too — these
  operations take no parameters and their base URL is already parsed, so the only way
  either fails before a byte leaves is the bearer provider, and a dead session must
  reach a caller as one rather than as a transport blip.

Both in-repo mocks were answering whichever path they were handed, which is why the
wrong route survived. They now route on `/v1/auth/escrow` and answer `501` elsewhere,
so a route regression fails loudly instead of reading as "no escrow stored", and their
refusals carry real RFC 9457 bodies because a generated operation decodes them.

The proof the old tests could not give is a new case in
`capsule-server/tests/sdk_client.rs`: the SDK stores and fetches a real wrap over a
socket against the assembled router, and asserts the bytes come back byte-identical
and still open under the recovery secret.

Refs #408
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 732967f
Status: ✅  Deploy successful!
Preview URL: https://d25a7761.capsule-22k.pages.dev
Branch Preview URL: https://fix-sdk-escrow-route-and-401.capsule-22k.pages.dev

View logs

…oute

Three defects an adversarial read of the previous commit turned up.

**An unreachable server was reported as an expired session.** reqwest builds every
failure of the request it executes with `error::request(..)`, so `is_request()` is true
for connection-refused, DNS and TLS failures, and spargen's taxonomy files all of them
under `RequestConstruction` next to the genuine pre-flight ones. Mapping that class to
`Unauthorized` therefore told an offline device to sign in again — the one remedy that
cannot work without a network. The source discriminates them instead: a bearer provider
that could not mint a token is boxed as the generated runtime's own `AuthError`, and
nothing else on this path is. A closed port is now `Transport`, with a test that binds a
socket, drops it, and points the client at the address.

**`error_code()` guessed where it should have read.** A `400` reported
`error.escrow.malformed` even when the server said otherwise; a `413` reported it too,
so a client localizing the code would tell a user their recovery blob was corrupt when
it was merely too large; and the `500`'s `error.escrow.unavailable` — the one code that
route bothers to set — was thrown away into a transport string. `Malformed` now carries
the server's own code, `413` carries `error.request.too_large`, and `500` is its own
`Unavailable` variant. The mocks stop inventing `error.*` strings that exist in no
catalog and use `capsule_i18n::error_codes` throughout.

`FfiError::Escrow` gains the `code` the enum's own doc already promised every variant
carries, so `error.escrow.not_stored` reaches a native client — the distinction between
"set up a recovery key" and "we could not read the one you have".

**The route was pinned by accident.** The socket test relied on a wrong path producing
something other than `NotEnrolled`, which held only because Kynos's unmatched-route
`404` carries no code and therefore fails to parse. It now asserts the route directly
through the fixture's in-process client: what the SDK stored is read back at
`/v1/auth/escrow`, and a rotation seeded at that path is what the SDK fetches next.
A client on any other path satisfies neither.

Relatedly, an uncoded `404` is deliberately *not* read as `NotEnrolled` any more. Reading
every `404` as "this account has escrowed nothing" is precisely what let a wrong route
look like an empty escrow for a whole slice; an intermediary answering `404 text/html`
is a broken path, not an enrollment state.

Refs #408
The generated client had only the proactive half of the refresh contract: the token
provider refreshes when the stored token is within its skew of expiry, before the
request leaves. That cannot cover a token the server stops honouring early — a
revocation mid-flight, or a clock the two ends disagree about — and the hand-written
clients closed that race years ago while the typed path did not.

`RefreshOn401` is an `rest::HttpBackend` wrapping `ReqwestBackend`, installed by
`AuthenticatedClient::build_client` through `Client::with_backend`. On a `401` it
refreshes once through a new `pub(crate) Session::refresh_rejected` and replays the
request once. It touches no generated code and covers every generated operation at
once, so there is no per-call retry loop to keep in step and nothing to redo when the
document is re-sourced.

Why the transport seam and not spargen's `Middleware`: `Next::run` takes `self` by
value, and `Next` is neither `Clone` nor constructible outside the generated runtime, so
a middleware physically cannot send twice. `RetryBackend` is the precedent this follows,
including its rule that a request whose `try_clone()` is `None` — a one-shot streaming
body — is executed once and never replayed.

`Session::refresh_rejected` wraps `ensure_refreshed(RefreshTrigger::Rejected(stale))`
rather than reusing `Session::refresh`, because `refresh` re-reads the *current* token
and would refresh again on top of a concurrent rotation, spending a single-use refresh
token the server had already closed. Passing the exact token the server refused is what
lets the existing single-flight gate coalesce.

Exactly once, and by construction: the replay is straight-line code, not a loop with a
counter. Four properties are pinned as unit tests — one refresh and one replay carrying
the rotated token; a persistent `401` surfaced after exactly two upstream requests; a
request with no bearer never retried; and a refresh that itself fails surfacing the
**server's** `401` rather than a synthesized transport error, so the typed `Status401`
mapping still fires and the caller reads the `error.*` code that separates an expired
token from an unreadable revocation ledger.

Over a socket, `a_token_the_server_stopped_honouring_is_refreshed_and_the_call_replayed`
reproduces the race against the real router: the server validates `exp` against its
injected clock, so advancing the fixture past `ACCESS_TOKEN_TTL` revokes the access token
for real while the refresh token lives, and the client is handed the same pair with a
far-future deadline. The pre-flight half cannot fire, so the call only succeeds through
the reactive layer. Both new tests were confirmed to fail with the backend uninstalled.

`reqwest_client()` becomes one shared client for the process. It owns a connection pool,
and the FFI's escrow verbs build a fresh `AuthenticatedClient` per call because the API
root is a per-call argument — so a per-client transport meant a fresh TLS handshake for
every escrow read. Nothing here is configured per instance, so there is nothing to vary.

Refs #408
`POST /v1/albums/{album_id}/upgrade` had no client. It is one of the four
`application/cbor` operations `build.rs` narrows out of the generated client — spargen
0.4's `classify_media` does not know that media type — and it was the only one of the
four with nothing hand-written behind it, so the SDK could not start the ceremony at
all.

`capsule_sdk::upgrade::UpgradeClient::begin` posts the signed intent **verbatim**. The
bytes are the canonical CBOR `capsule_core::crypto::upgrade` signed, and the server
verifies that signature against the proposing device's DSK in the account's published
directory; re-encoding them here would detach them from the signature and the failure
would look like a forged proposal.

Every refusal keeps its own identity and the code the *server* stamped, because these
are the refusals an admin reads: `409 error.album.upgrade_in_flight` carries the live
`intent_id`, `403 error.album.upgrade_proposer` means the signing device is not
published, and a client that flattened either into "malformed" would have someone
re-signing intents forever. The `413` body backstop carries no problem body at all, so
its code is the client's — `error.request.too_large`, not the intent-malformed code.

The phase decodes into typed ids and a `jiff::Timestamp`, so a caller compares instants:
the deadline is the one field in this ceremony where a string comparison would be a
correctness bug rather than an inconvenience. An unparseable deadline is a malformed
response, never a silent `None`, which would tell a client the ceremony never expires.

`GET` and `DELETE` on the same path are plain JSON and *are* generated; the module doc
says so and deliberately does not duplicate them.

Proven over a socket in `the_sdk_proposes_an_album_upgrade_over_a_socket`, which is the
only shape that can prove anything here: the directory is anchored, the album
provisioned, and the intent signed with the same `capsule-core` types the server
verifies with, so what the test asserts is that the bytes the SDK put on the wire are
the bytes that verify. A mock answering `200` would have proven only that the client can
post.

Refs #408
Five standing falsehoods in `capsule-sdk`'s own documentation, and the two `SLICES.md`
rows this issue moves.

- The document is **OpenAPI 3.2** and has been since Kynos was pinned with
  `openapi_as(SpecVersion::V3_2)`. `lib.rs` said 3.1 twice and `build.rs` once.
- `mise run openapi` does not exist. The tasks are `openapi-kynos` and
  `openapi-check-kynos`.
- `build.rs` said `capsule_sdk::directory` hand-writes two of the four
  `application/cbor` operations and "the other two have no client yet". One of those two
  had a client all along (`verify::StorageVerifyClient::fetch_receipt`) and the other
  now does (`capsule_sdk::upgrade`), so all four are named, with the one upstream change
  that retires all four.
- The sync feed is not gRPC. `lib.rs` said `sync` stays hand-written because its
  protocol is too stateful for codegen, which is true of `upload` and false of `sync`:
  `S-D28` made the feed `GET /v1/sync`, a generated operation, and what is hand-written
  is the cursor and anti-rewind state machine over it. `ffi/tests.rs` still called
  `sync_pull` gRPC, and `FfiError`'s doc still offered foreign apps a "bare HTTP/gRPC
  status" to avoid.

`SLICES.md`: `S-D12` records the route defect and its closure, and carries the escrow
store response as an owed item pointing at #442. `S-D17` flips to `MIXED | done` — the
Area corrects because the layer is live code in this workspace that does not re-scope,
even though the client under it is regenerated — with the backend, the rejected
`Middleware` alternative, and the socket case named, plus the reason
`capsule_sdk::sync` keeps its own loop.

Refs #408
Three review findings on this branch.

**The client no longer invents an `error.*` code.** A body-less `413` carries no problem
body, so there is no code to carry — and both hand-written clients were filling that gap
with `error.request.too_large`. Every other code either module reports is the one the
*server* stamped; a code minted on this side asserts that the server said something it
did not, and a client localizing it reads the SDK's guess as the server's judgement. Both
sites now report `code: None` with the English detail, and the variant already carries
the actionable half ("these bytes will not do, do not resend them"). The upgrade test
that asserted the minted code now asserts its absence.

**The auth/transport split has a test on both sides.** `RequestConstruction` carries two
completely different events — a bearer the session could not mint, and a connection that
never opened — and only the boxed source separates them. The transport side was pinned;
the auth side was not, so a spargen change to how a provider failure is boxed would have
silently demoted every expired refresh token to `Transport`, and the FFI would tell a
user to retry where it must tell them to sign in again.
`a_session_that_cannot_mint_a_bearer_is_an_auth_failure` drives a session whose stored
token is past expiry against a mock that serves no `/refresh`, on both the read and the
write path. Confirmed to fail with the downcast arm disabled.

**`reqwest_client()`'s doc stops overclaiming.** Sharing one client for the process does
not remove every per-construction client: `Client::with_backend` still builds its own
default `reqwest::Client` internally, one per `AuthenticatedClient`. That one only
assembles requests — every byte is executed through the backend, and so through the
shared client — so it opens no connection and costs one throwaway allocation. The comment
now says so, and names the `with_client_and_backend` constructor spargen would need to
remove even that.

Refs #408
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.

1 participant