Skip to content

[FIX] Advertise and gate the protocol handshake on every route from one interceptor pair - #453

Open
justin13888 wants to merge 14 commits into
feat/server-binary-config-operator-commands-401from
fix/protocol-headers-every-route-404
Open

[FIX] Advertise and gate the protocol handshake on every route from one interceptor pair#453
justin13888 wants to merge 14 commits into
feat/server-binary-config-operator-commands-401from
fix/protocol-headers-every-route-404

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

Issue #404: the protocol headers the design puts on every route were declared on four operations, and the response half was never sent at all — X-Capsule-Protocol-Min/-Max rode as problem-body extension members because a Kynos ApiError has no response-header seam, while capsule-sdk/src/upload.rs reads them from headers and got None every time. The 426 recovery path was dead on both ends.

The seam exists on the interceptor, not the error. This PR adds capsule-server/src/negotiation.rs with two Kynos interceptors whose associated types are the declaration:

  • Negotiation — router-wide, outside the body-size limit. Adds the three response headers to every response the chain produces: a 413, an extractor's 400, the bearer scheme's 401, the gate's 426, a 200. Short = Infallible; it cannot refuse.
  • ProtocolGate — on a Kynos Group, which is how an exemption is spelled. Reads the three request headers, refuses 426 error.protocol.version_unsupported for a protocol outside [Min, Max] and 400 error.request.malformed for a missing/non-date protocol, a suite the inventory does not name, or a sidecar schema newer than the build indexes. Runs before the path, credential or body is looked at.

Both read one UploadPolicy window, which boot.rs now builds from PROTOCOL_MIN/PROTOCOL_MAX (it was UploadPolicy::default() regardless of config — a deployment that narrowed its window published one range on /.well-known/capsule/server-info and enforced another). X-Capsule-Min-Client-Build is advertised from the policy (0.0.0 = no cutoff announced; advisory per threat-model/validation.md).

routes/upload.rs loses its per-route handshake (ProtocolHeader, handshake*, four ProtocolUnsupported variants with protocol_min/max members, three MalformedRequest variants). POST /v1/upload keeps a body-level 426 for the manifest envelope's pin, without the window members. The four ProtocolRangeProblem rows for the upload operations leave openapi::EXTRAS; the album_lifecycle_op row stays because routes/ops.rs (outside this lane) still renders those members.

openapi::describe_negotiation_headers files the three response headers under every response of every operation — Kynos describes an interceptor's Adds on success responses only — so the emitted document promises exactly what the wire carries. openapi.json is regenerated.

Tests: the fixture's client is now a support::Client wrapper that sends x-capsule-protocol on every request (as the SDK does), with raw() for the cases about its absence. tests/conformance.rs gains a document census (every response of every operation declares the three headers, required; the operations declaring the handshake are exactly the pinned GATED set and none of the design's nine EXEMPT) and a wire census driven by the document (every operation answers with the window; gated ones refuse 426/400 on it before auth; exempt ones never 426).

Every operation but the ten the design exempts is gated (49 of 59), by one of two gates: the 34 non-safe operations sit behind ProtocolGate (a grammatical date outside the window is 426), the 15 gated GET/HEAD operations behind ProtocolReadGate (admitted at any grammatical date — "reads of any past version succeed"; a future date on a read is admitted too, the design being silent), and a missing or malformed X-Capsule-Protocol is 400 error.request.malformed on all 49. Because an interceptor's declaration is its type, a read declares the 400 alone and no 426 it never renders. GET /v1/version, the four /.well-known/capsule/* records, the three /s/{opaque_id}* reads and the two /d/{opaque_id}* guest deposits are mounted on the router and carry only the response headers.

Config: PROTOCOL_MIN/PROTOCOL_MAX default to the policy's year window (they defaulted to the single day core speaks, collapsing a fresh deployment's window to one date), parse as strict YYYY-MM-DD dates, and may be equal; MIN_CLIENT_BUILD (semver-validated, default 0.0.0) reaches the policy and the header. The browser client (capsule-web/src/lib/api.ts) sends X-Capsule-Protocol on all five request builders.

SDK: capsule_sdk::net::http_builder()/http_client() is the one place a reqwest client is built — rustls only, X-Capsule-Protocol and X-Capsule-Crypto-Suite installed once as default headers — and AuthClient::new (which every Session-driven path rides), SyncConsumer, the generated rest::Client behind AuthenticatedClient, dial_client() and the CLI's version probe all obtain their client from it. The document declares X-Capsule-Protocol required on every gated operation, so the spargen-generated signatures gain a leading x_capsule_protocol: impl Into<String> argument and a Params struct for the two optional headers; the SDK's callers pass capsule_core::crypto::primitives::PROTOCOL_VERSION.

Summary

  • capsule-server/src/negotiation.rs (new): ProtocolRequestHeaders, NegotiationResponseHeaders, NegotiationRejection, negotiate, Negotiation, ProtocolGate, unit tests.
  • capsule-server/src/lib.rs: CodedProblems → Negotiation → BodySize on the router; ProtocolGate on a Group holding the four upload operations; ServerRouter alias; describe_negotiation_headers call.
  • capsule-server/src/routes/upload.rs: per-route handshake removed.
  • capsule-server/src/openapi/mod.rs: the document walk; four EXTRAS rows removed.
  • capsule-server/src/upload/policy.rs: min_client_build.
  • capsule-server/src/boot.rs: policy from the configured window and client-build cutoff; tests.
  • capsule-server/src/config.rs, capsule-server/.env.example: window defaults and date validation, MIN_CLIENT_BUILD.
  • capsule-web/src/lib/api.ts: X-Capsule-Protocol on every request; capsule-web/src/lib/api.test.ts (new) asserts it on all five builders and pins PROTOCOL_VERSION to capsule-core/src/crypto/primitives.rs.
  • capsule-server/openapi.json: regenerated (OpenAPI 3.2.0).
  • capsule-server/tests/{support/mod.rs,conformance.rs,upload.rs}: fixture client, census tests, header assertions.
  • capsule-sdk/src/net.rs: protocol_headers(), http_builder(), http_client(); dial_client() built on the same builder.
  • capsule-sdk/src/{client.rs,auth.rs,sync.rs}: every transport from net::http_client(); sync_feed/get_quota callers pass the protocol date; client::tests::every_request_carries_the_protocol_handshake.
  • capsule-cli/src/status.rs: the version probe over net::http_client().
  • capsule-docs/src/content/docs/design/api-surfaces.md: the carriage and the exempt list.

Validation

All commands run inside the worktree (base feat/server-binary-config-operator-commands-401 at 459e8af3, merged by merge commit).

Round 4 (head 032b6af2, confirming round: the web handshake test):

Command Outcome
cd capsule-web && bun test src/lib/api.test.ts 6 pass, 0 fail (five builders + the primitives.rs literal)
mise run check-web exit 0: [format-check-web] Checked 77 files in 24ms. No fixes applied. · [lint-check-web] Checked 77 files in 51ms. No fixes applied. · [test-web] 65 pass / 0 fail · [build-web] ready built in 0.40s
mise run check-commits origin/feat/server-binary-config-operator-commands-401 no errors in 12 commits

The one file added is a bun:test file under capsule-web/; no Rust source changed since 892f7575, whose check-rust/test-rust results below stand for this head.

Round 3 (head 892f7575, review round 1: read/write split, config validation, web client):

Command Outcome
mise run openapi-kynos + census openapi: 3.2.0; 59 operations, 49 gated: 34 writes declare 400+426, 15 reads declare 400 and no 426; 0 responses missing a window header
cargo nextest run -p capsule-server --no-fail-fast 761 passed (census asserting the read/write split, per-module table, 413 loop with window headers, 401 case, config date/semver tests)
mise run check-web exit 0: format-check-web, lint-check-web (76 files), test-web, build-web
mise run check-rust exit 0, every sub-task; the same two pre-existing unused imports warnings in capsule-core/src/library/receipts.rs:96
mise run test-rust exit 0: cargo nextest run --workspace 1820 passed; -p capsule-core --features ffi 729 passed; -p capsule-sdk --features ffi 162 passed
mise run check-docs-truth all resolve
mise run lint-check-md 0 issues
mise run check-docs exit 0, no tracked file changed
mise run check-commits origin/feat/server-binary-config-operator-commands-401 no errors in 11 commits

Round 2 (head 7526395b, the gate on every non-exempt operation; 7526395b itself only drops a redundant #[must_use] clippy refused on d7d6f532):

Command Outcome
mise run openapi-kynos + census of the file openapi: 3.2.0; 59 operations, 49 declaring X-Capsule-Protocol required (59 − 10 exempt), 0 responses missing a window header
cargo check -p capsule-sdk -p capsule-cli --tests clean after the callers were adjusted (sync_feed, get_quota, SyncFeedError::Status426)
cargo nextest run -p capsule-server --no-fail-fast 758 passed — census (document + wire + one route per module), coverage walk producing 426/400 on all 49, tests/sdk_client.rs over a socket, tests/binary.rs byte-for-byte document
cargo nextest run -p capsule-sdk -p capsule-cli --no-fail-fast 177 passed
mise run check-rust on d7d6f532: lint-check-rust failed on one clippy::double_must_use in capsule-sdk/src/net.rs (caused, fixed in 7526395b); on 7526395b: exit 0, every sub-task (format-check-rust, lint-check-rust, doc-check-rust, i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, translate-readme-check, build-rust, build-check-wasm, build-ffi, lint-check-ffi, gen-bindings, verify-examples); the same two pre-existing unused imports warnings in capsule-core/src/library/receipts.rs:96
mise run test-rust on 7526395b: exit 0 — cargo nextest run --workspace 1817 passed; -p capsule-core --features ffi 729 passed; -p capsule-sdk --features ffi 162 passed (also observed on d7d6f532: 1817 / 729 / 162)
mise run check-docs-truth all resolve (endpoint census, module paths)
mise run lint-check-md 0 issues
mise run check-docs exit 0, no tracked file changed
cargo fmt --all -- --check clean
mise run check-commits origin/feat/server-binary-config-operator-commands-401 no errors in 7 commits

Round 1 (head b4881d30, four gated operations), kept for the record:

Command Outcome
cargo nextest run -p capsule-server --no-fail-fast 757 passed
cargo nextest run -p capsule-sdk --no-fail-fast 151 passed
mise run check-rust exit 0, every sub-task; two unused imports warnings in capsule-core/src/library/receipts.rs:96pre-existing, file untouched by this PR
mise run test-rust exit 0: 1815 / 729 / 161 passed
mise run check-docs, check-docs-truth, lint-check-md, cargo fmt --check, check-commits all clean

One environmental failure during the run: the shared scratch volume filled (ENOSPC) between the first server suite and the document regeneration; classified unavailable, cleared without deleting anything, and every gate above was run after it.

Risks and rollout

  • Breaking for any client that is not this SDK or the in-tree web client: 45 operations that accepted a request without X-Capsule-Protocol now refuse it with 400 error.request.malformed, and every gated write refuses an out-of-window date with 426; reads are admitted at any date. This is the fail-closed contract the design states and server: the protocol headers the design puts on every route are on four operations #404 was filed against. The in-tree browser client sends the header (round 3); there is no released third-party client.
  • Breaking on the frozen SDK's generated surface: every gated generated operation's signature changes (x_capsule_protocol first argument; a Params struct where there was none). In-workspace callers are updated; an out-of-tree caller of capsule_sdk::rest passes PROTOCOL_VERSION. The hand-written SDK paths (upload, albums, verify, directory, fetch) are unchanged in shape.
  • The upload 426 no longer carries protocol_min/protocol_max in the body; it carries them on X-Capsule-Protocol-Min/-Max, which is where the SDK already reads them. The missing-header 400 on the upload operations carries error.request.malformed instead of error.upload.malformed_request.
  • Every response grows three headers (~90 bytes).
  • A request to an unrouted path (404/405 from the router itself) carries no window: interceptors run per operation, after routing. Kynos limitation; recorded.
  • Rollback: revert the PR. openapi.json regenerates from the router either way.

Related Issues

Closes #404. Refs #451 (the follow-up filed when this lane stopped at its manifest; the widening landed here after the orchestrator widened the manifest — see "Decisions taken" 16). Forecast collision: #430 (chore/retire-capsule-wire-400) removes capsule-wire; this tree adds no use of it (decision 23).

Decisions taken

Issue 404 - server: the protocol headers the design puts on every route are on four operations
Plan:     B-1 (planned against f433d918; executed on the head of lane #401's branch)
Branch:   fix/protocol-headers-every-route-404
Base:     feat/server-binary-config-operator-commands-401 (head of the W-SERVE PR), stacked; the PR targets that branch
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-fix-protocol-headers-every-route-404
Cause:    Kynos ApiError has no response-header seam, so routes/upload.rs:46-53 put the window in problem extensions and no other route was given the headers; capsule-sdk/src/upload.rs:1046 reads them from headers the server never sends
Touches:  capsule-server/src/negotiation.rs (new: ProtocolRequestHeaders, NegotiationResponseHeaders, NegotiationRejection, Negotiation + ProtocolGate interceptors), capsule-server/src/lib.rs (router stack; ServerRouter alias; exempt Group), capsule-server/src/routes/upload.rs (per-route protocol duplication removed), capsule-server/src/openapi/mod.rs (describe_negotiation_headers; five ProtocolRangeProblem rows removed), capsule-server/src/upload/policy.rs, capsule-server/src/boot.rs (one ProtocolWindow from config), capsule-server/tests/{conformance.rs (header census), support/mod.rs (fixtures send the headers), upload.rs}, capsule-server/openapi.json (regenerated), capsule-sdk/src/client.rs (default headers on the shared reqwest client), capsule-docs/src/content/docs/design/api-surfaces.md (record the exemption list), SLICES.md (no existing slice row — this is audit finding D4; the lane adds no row)
Will not: enforce X-Capsule-Min-Client-Build (advisory per validation.md:133); touch peering's protocol space; add a locales/ key
Lane:     serialised behind #401 (lib.rs middleware stack, boot.rs). Parallel with #402/#403. Forecast collision: capsule-server/tests/support/mod.rs and openapi.json with #407/#405 (all regenerate the document — merge order decides; regen is mechanical).
Settled:  OpenAPI 3.2 emitted from the router's types, never hand-edited (AGENTS.md). Barrels (#399). Base = head of PR #418 → stacks on #399 → #401.

Decisions taken.

1. Deliverable boundary - server plus SDK in one lane.
   Taken:    Server gate, response advertise, document, census test, and the SDK's request headers.
   Rejected: server half only - the gate makes X-Capsule-Protocol mandatory on 50 operations; the generated SDK sends none today, so tests/sdk_client.rs and every SDK caller would 400.
   Reverses: revert the capsule-sdk/src/client.rs commit.
   Filed:    -

2. Where the declaration comes from
   Taken:    Interceptor associated types (Reads/Short) for request params and the 426, plus one post-emit walk (openapi::describe_negotiation_headers) for the three response headers on every response — kynos-0.1.0/src/middleware/erased.rs:88 attaches Adds only at StatusPattern::Success.
   Rejected: annotating 59 operations by hand; a components.parameters entry referenced per operation (still 59 references).
   Reverses: delete the describe_negotiation_headers call and regenerate.

3. Which routes are exempt from the request gate
   Taken:    /v1/version, the four /.well-known/capsule/*, the three /s/{opaque_id}* reads, POST /d/{opaque_id} and PATCH /d/{opaque_id}/{upload_id} — nine of 59, mounted on the router while ProtocolGate sits on a Group; all still receive the response headers.
   Rejected: gate literally every route - validation.md:139-143 scopes fail-closed to writes; share-links.md:32 requires an indistinguishable 404 (a 426 is a probing oracle); web-upload.md:56 pins protocol_version/crypto_suite_id at link issuance.
   Reverses: move the exempt mounts inside the gated Group.

4. How the SDK sends the three request headers
   Taken:    Default headers on the shared reqwest::Client (client.rs:130-135) — covers the generated client and the hand-written upload/directory/fetch paths from one place.
   Rejected: a spargen Middleware - reaches only the generated client while upload.rs sets X-Capsule-Protocol per call at :664,:706,:745,:770,:802; two mechanisms drift.
   Reverses: drop .default_headers(..) and add a MiddlewareBackend layer.

Decisions taken inside the manifest (this lane).

5. Which operations the gate covers in this PR
   Taken:    The four upload session operations (POST /v1/upload, PATCH|HEAD|DELETE /v1/upload/{id}) — the set that enforced the handshake per route before the gate existed — mounted in the ProtocolGate Group; every other non-exempt operation stays on the router, ungated, and still carries the response headers. The census test pins the set.
   Rejected: gating every non-exempt operation now, as decisions 1 and 3 planned. Their premise — "the shared reqwest::Client" in client.rs — is false: capsule-sdk/src/auth.rs (AuthClient::new; Session::execute rides it), sync.rs (build_client) and net.rs (dial_client) each build their own client and send no handshake, so widening refuses every SDK sign-in and feed pull and turns capsule-server/tests/sdk_client.rs red. Declaring the parameters on those operations also reshapes the spargen-generated signatures (a required header is a positional argument; an optional one grows a Params struct), breaking callers in sync.rs, verify.rs, client.rs and capsule-cli/src/status.rs. All of those files are outside this lane's manifest, and the manifest gate says stop rather than widen.
   Reverses: move the five non-exempt mount sets in lib.rs::router into the Group and flip GATED in tests/conformance.rs to the complement of EXEMPT — once the SDK transports send the handshake.
   Filed:    #451 (server/sdk: widen the protocol gate to every non-exempt operation), with the manifest the widening needs.

6. X-Capsule-Crypto-Suite and X-Capsule-Sidecar-Schema are validated when present, not required
   Taken:    Only X-Capsule-Protocol is required. A present suite the inventory does not name, or a sidecar schema above MAX_KNOWN_SIDECAR_SCHEMA, is 400 error.request.malformed; absence is not refused.
   Rejected: requiring both on every gated operation - the design scopes the suite to writes and the schema to metadata updates, every write already carries its suite in a body the envelope gate checks, and nothing today sends the schema header.
   Reverses: make the two Options required in negotiate().

7. The client-build cutoff lives on UploadPolicy
   Taken:    UploadPolicy::min_client_build, default "0.0.0" ("no cutoff announced"), advertised on every response; no config plumbing.
   Rejected: a new context on App (app.rs is outside the manifest); omitting the header when no cutoff exists (a client could not tell an unannounced cutoff from a server that never speaks the header).
   Reverses: drop the field and the header from the declarations list.

8. The server states its own maximum sidecar schema
   Taken:    negotiation::MAX_KNOWN_SIDECAR_SCHEMA = 1, documented against capsule-core/src/sidecar/sidecar_v1.rs.
   Rejected: reading capsule_core's SIDECAR_SCHEMA_V1 - it is crate-private behind the frozen barrel (#399).
   Reverses: replace the constant with a re-export once core publishes one.

9. The SDK's default headers are the two build constants
   Taken:    X-Capsule-Protocol and X-Capsule-Crypto-Suite as default headers; protocol_headers() is public for the other transports. X-Capsule-Sidecar-Schema is not sent.
   Rejected: sending the schema on every request - it is a property of one metadata write, and the SDK cannot reach the constant either.
   Reverses: add the third header to protocol_headers().

10. The enforced window is the configured one
   Taken:    boot.rs builds UploadPolicy::default().with_protocol_window(config.protocol_min, config.protocol_max). Before, the policy ignored PROTOCOL_MIN/PROTOCOL_MAX and a narrowed deployment published one range and enforced another. Asserted by boot::tests::the_enforced_and_advertised_window_is_the_configured_one.
   Rejected: leaving the default - the interceptors would advertise a window the discovery record contradicts.
   Reverses: restore UploadPolicy::default() in boot.rs.

11. One ProtocolRangeProblem row stays
   Taken:    The four upload rows leave openapi::EXTRAS; album_lifecycle_op's row stays because routes/ops.rs (outside the manifest) still renders protocol_min/protocol_max as body members on its 426.
   Rejected: removing all five as the record's Touches line says - the document would stop describing members the response carries, which is the S-C38 regression class.
   Reverses: strip the members in routes/ops.rs and delete the row.

12. X-Capsule-Protocol is declared required on the gated operations
   Taken:    required: true in the document (the server refuses without it). The generated create_upload/append_chunk/head_upload/cancel_upload gain a positional x_capsule_protocol argument; nothing in the workspace calls them (the SDK's upload path is hand-written).
   Rejected: leaving it optional as the old derive did - a required header documented as optional is a contract the server does not keep.
   Reverses: .required(false) in ProtocolRequestHeaders::parameters.

13. The test fixture sends the handshake once
   Taken:    tests/support::Client wraps TestClient, sets x-capsule-protocol on every request, exposes raw() for the cases about its absence; not Deref, so a &TestClient parameter cannot silently accept it.
   Rejected: editing every request site in the suite; leaving the fixture bare (the widening in #451 would then touch every test file).
   Reverses: type fixture.client back to TestClient<App>.

14. The design exempts ten operations, not nine
   Taken:    1 (/v1/version) + 4 (/.well-known/capsule/*) + 3 (/s/{opaque_id}*) + 2 (/d/{opaque_id}*) = 10; the prose in lib.rs, conformance.rs and api-surfaces.md says ten. Decision 3's "nine" is a miscount of the same list.
   Reverses: -

16. Manifest widened by the orchestrator for the contract (round 2)
   Taken:    capsule-sdk/src/{auth.rs,sync.rs,net.rs,client.rs,verify.rs,build.rs}, capsule-cli/src/status.rs, capsule-server/src/lib.rs, capsule-server/tests/conformance.rs and capsule-server/openapi.json join the manifest; the gate covers every operation but the ten exemptions in this PR. Decision 5 is superseded. verify.rs and build.rs turned out to need no change (verify.rs has no generated-client call; spargen needed no omit rule once the callers pass the date).
   Rejected: shipping four gated operations and #451 as a follow-up - a half-gated contract is what D4 was filed against, and W-E2E (#409) asserts the contract against this PR's head.
   Reverses: revert the round-2 commits; the four-operation group in 8eee2131 stands on its own.
   Filed:    #451 stays open as the run's record, with one comment pointing at the head that landed the widening.

17. One HTTP client for the SDK
   Taken:    capsule_sdk::net::http_builder() installs the two handshake headers; http_client() builds it; dial_client() adds its connect timeout on top of the same builder. AuthClient::new, sync::build_client, client::reqwest_client and the CLI's version probe all go through it. Decision 4's premise ("the shared reqwest client") is now true by construction; nothing in the crate calls reqwest::Client::builder() outside tests.
   Rejected: a shared reqwest::Client value - dial_client's connect timeout is a real behavioural difference for the upload/fetch paths and folding it into the auth and sync clients would change their behaviour beyond the headers.
   Reverses: inline the builders again.

18. Callers of generated operations pass the build's protocol date
   Taken:    sync.rs and client.rs pass capsule_core::crypto::primitives::PROTOCOL_VERSION as the first argument and `..SyncFeedParams::default()` for the optional headers; the transport sends the same value as a default header, so the two cannot disagree.
   Rejected: a spargen omit rule in build.rs hiding the three parameters from the generated surface - a required header the generated client did not expose would be a contract the client could not state.
   Reverses: drop the argument if spargen grows a transport-supplied-parameter rule.

19. The crypto suite stays validated-when-present on writes
   Taken:    validation.md's fail-closed rule is "X-Capsule-Crypto-Suite not in the inventory: 400"; it does not refuse absence, and every write already carries its suite in a body the envelope gate checks. Not declared required on any operation.
   Reverses: mark it required on the write operations in ProtocolRequestHeaders::parameters and refuse absence in negotiate().

20. Reads are held to the handshake's grammar, writes to its window (review Q1 → b, F2)
   Taken:    Two gates on two Groups: ProtocolGate (non-safe methods; grammatical date outside the window → 426) and ProtocolReadGate (GET/HEAD; any grammatical date admitted). Missing or malformed X-Capsule-Protocol is 400 error.request.malformed on every gated operation. validation.md:139 scopes the 426 to a write and :142 says reads of any past version succeed; it is silent on a future date on a read, so a read admits any grammatical value and the module docs record that. Reads declare the 400 alone in the document.
   Rejected: (a) amending the three doc sentences to fit a uniform 426; (c) a single gate reading the method at run time — its Short would declare 426 on reads that never render it.
   Reverses: mount the read operations in the write group and delete ProtocolReadGate.

21. The window defaults to the policy's year window and is validated (review Q2 → b, F1, F4)
   Taken:    PROTOCOL_MIN/PROTOCOL_MAX default to DEFAULT_PROTOCOL_MIN/MAX (2026-01-01..2026-12-31), parse as strict jiff::civil::Date with a round-trip check (2026-6-1 and 2026-02-30 are ConfigFault::Invalid), and min == max is a legitimate choice. .env.example documents both; local-development.md is not in this lane's manifest and was not edited (it names no PROTOCOL_* variable today).
   Rejected: (a) the single-day default nobody chose; (c) documenting the collapse.
   Reverses: restore the PROTOCOL_VERSION defaults in config.rs.

22. The missing-header code on the upload surface changed (review Q3 → a)
   Taken:    A missing X-Capsule-Protocol on POST/PATCH/HEAD/DELETE /v1/upload* is 400 error.request.malformed (the gate's), where it was 400 error.upload.malformed_request (the route's). In-workspace callers switch on neither; there is no released client. error.upload.malformed_request stays in the catalog for the body-level cases routes/upload.rs and routes/ops.rs still render; the i18n catalog owner may retire nothing yet.
   Reverses: -

23. negotiation.rs is the single home of the six header names (review Q4 → a, F5)
   Taken:    The constants live in capsule-server/src/negotiation.rs and nowhere else in the server; the module docs say so. No new use of capsule-wire is added: #430 (chore/retire-capsule-wire-400, a sibling branch) retires it, and this tree still links it only through pre-existing paths.
   Rejected: (b) re-exporting from capsule_wire::headers — broken at the #430 merge.
   Reverses: -

24. The in-tree browser client sends the handshake (review F3)
   Taken:    Manifest widened by capsule-web/src/lib/api.ts and, in the confirming round, capsule-web/src/lib/api.test.ts (new). All five fetch builders send X-Capsule-Protocol from a PROTOCOL_VERSION constant restated in api.ts, with a comment naming capsule_core::crypto::primitives::PROTOCOL_VERSION as the source of truth (the wasm surface does not export it). api.test.ts drives the five builders against a recording mock of the global fetch and asserts the header on each, and reads capsule-core/src/crypto/primitives.rs at test time so the restated constant fails the suite the day it drifts. No S-C62 drift-table row: the drift does not remain.
   Rejected: leaving web sign-in broken with a drift-table row.
   Reverses: drop the header lines and the constant.

25. Review repairs F6–F10, and one not taken
   F6:  Not applicable after decision 20 — GET /v1/sync is a read, admits any grammatical date, and declares no 426, so the generated SyncFeedError has no Status426 to map and a SyncError::ProtocolUnsupported would be unreachable. The consumer maps 400/401/403/500 and reads the window from the response headers if it ever needs to. Recorded rather than adding dead code.
   F7:  X-Capsule-Crypto-Suite and X-Capsule-Sidecar-Schema are declared (optional) on every gated operation, reads included, because the interceptor's Reads type is the declaration and one type is mounted on both groups; validated-when-present stands (decision 6). Recorded in negotiation.rs.
   F8:  The 413 loop asserts the three window headers; a 401 case (valid protocol, no bearer, GET /v1/quota) asserts them too.
   F9:  MIN_CLIENT_BUILD → Config::min_client_build → UploadPolicy::with_min_client_build; MAJOR.MINOR.PATCH validated; .env.example line; boot test reads it back through the surface.
   F10: With both window ends and the cutoff validated at the configuration boundary, NegotiationResponseHeaders::encode treats an unencodable value as a programming error (expect with the reason; a should_panic test pins it).
   Constructors: AuthClient::from_parts, UploadTransport/AlbumTransport/VerifyTransport::with_static_token document that the caller-supplied client must come from net::http_builder.

15. Residuals not taken
   InMemoryTotp duplication in tests/support/mod.rs (#435's residual): not one hunk; left.
   error.request.too_large on the body-less 413 backstop (#434's note): problem.rs/limits.rs are outside this manifest; left as a residual — W-E2E should keep treating a body-less 413 as code: None.

Unresolved review notes

(none)

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

…rceptor pair

The design puts three request headers and three response headers on every
route; the server declared the request half on four upload operations and
never sent the response half at all. A Kynos ApiError has no response-header
seam, so routes/upload.rs rode X-Capsule-Protocol-Min/-Max as problem
extension members while capsule-sdk reads them from headers and got None.

The seam is on the interceptor. negotiation.rs adds two: Negotiation, mounted
router-wide outside the body-size limit, attaches the window to every
response the chain produces, errors and short-circuits included; ProtocolGate,
on a Group, reads the three request headers and refuses 426 or 400 before the
handler runs. Both read the one UploadPolicy window, which gains the advisory
min_client_build. The group holds the four upload session operations that
enforced the handshake per route until now; routes/upload.rs loses that
duplication, and the 426 keeps no window members in its body.

openapi::describe_negotiation_headers files the three response headers under
every response of every operation, since Kynos describes an interceptor's
headers on success responses only. The test fixture's client sends the
handshake on every request with raw() for its absence, and conformance.rs
gains a document census and a document-driven wire census that pin the gated
and exempt sets.

Refs #404
boot::assemble built the upload policy from UploadPolicy::default() regardless
of PROTOCOL_MIN and PROTOCOL_MAX, so a deployment that narrowed its window
published one range on /.well-known/capsule/server-info and enforced another
on POST /v1/upload. The policy is now built from the configured window, which
is also what the negotiation interceptors advertise and refuse against. The
new boot test reads both back through the surface.

Refs #404
Every gated operation requires X-Capsule-Protocol and refuses without it. The
shared reqwest client behind the generated REST client now carries that header
and X-Capsule-Crypto-Suite as defaults, from the build's own constants, so the
generated operations and the hand-written paths over the same transport send
them with no per-call argument. protocol_headers() is public so the SDK's other
transports can carry the same handshake from the same source.

Refs #404
@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: 032b6af
Status: ✅  Deploy successful!
Preview URL: https://4f43e8f8.capsule-22k.pages.dev
Branch Preview URL: https://fix-protocol-headers-every-r.capsule-22k.pages.dev

View logs

The ProtocolGate group now holds every non-exempt operation; GET /v1/version,
the four /.well-known/capsule/* records, the three /s/{opaque_id}* reads and
the two /d/{opaque_id}* guest deposits stay on the router and carry only the
response headers. The census in tests/conformance.rs asserts the gated set is
exactly the complement of the pinned ten, the coverage walk produces the 426
and 400 every gated operation now declares, and one representative route per
module is refused before a credential or body is read. The boot tests that
register and sign in send the handshake, as every client does.

Refs #404
net::http_builder installs X-Capsule-Protocol and X-Capsule-Crypto-Suite as
default headers once; http_client builds it and dial_client adds its connect
timeout on top of the same builder. AuthClient::new, the sync consumer, the
generated client behind AuthenticatedClient and the CLI's version probe all
obtain their client there, so no SDK request reaches a gated route without
the handshake. The document now declares X-Capsule-Protocol required on every
gated operation, which puts the protocol date first in each generated
signature; the SDK's callers pass the build's own constant, the same value the
transport sends, and the sync consumer maps the 426 the feed can now answer.

Refs #404
reqwest::ClientBuilder is already must_use; clippy's double_must_use refuses
the second attribute under the repo's -D warnings.

Refs #404
… window

threat-model/validation.md scopes the 426 to a write and says reads of any
past version succeed. The gate is now two: ProtocolGate on the group of
non-safe operations refuses a grammatical protocol date outside the window
with 426, and ProtocolReadGate on the group of GET and HEAD operations admits
any grammatical date and refuses only a missing or malformed handshake with
400. Because an interceptor's declaration is its type, a read now declares the
400 alone and no 426 it never renders; the census asserts exactly that, the
coverage walk and the per-module table follow the split, the 413 loop and a
new 401 case assert the window headers on refusals the gates did not make,
and the feed's dead 426 arm leaves the SDK's sync consumer.

Refs #404
PROTOCOL_MIN and PROTOCOL_MAX defaulted to the single day capsule-core speaks,
collapsing the window a fresh deployment accepts to one date, and neither was
checked for shape, so 2026-6-1 sorted before 2026-12-31 for the wrong reason.
Both now default to the policy's year window, parse as strict YYYY-MM-DD
dates, and may be equal. MIN_CLIENT_BUILD joins them, validated as
MAJOR.MINOR.PATCH and handed to the upload policy, so the advisory
X-Capsule-Min-Client-Build on every response is the operator's value rather
than the crate default. With both validated at the boundary, an unencodable
window value is a programming error and the response encoder says so.

Refs #404
Every transport constructor that accepts a reqwest::Client names
net::http_builder as its source; a client built any other way sends no
protocol handshake and every gated route refuses it.

Refs #404
Every gated route now refuses a request without X-Capsule-Protocol, and the
hand-written web auth client sent none, so sign-in, registration, refresh and
every authenticated call from the browser were refused. All five request
builders send the protocol version this client is written against, restated
from capsule_core::crypto::primitives::PROTOCOL_VERSION because the browser
holds no Rust and the wasm surface does not export it.

Refs #404
api.ts is hand-written, so the header could silently go missing again. A
recording mock of the global fetch drives the five request builders and checks
X-Capsule-Protocol equals PROTOCOL_VERSION on each, and PROTOCOL_VERSION is
compared with the literal in capsule-core/src/crypto/primitives.rs read at
test time, so the restated constant cannot drift from its source of truth.

Refs #404
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