Skip to content

[TEST] Land the bounded E2E cases in a capsule-e2e crate - #463

Open
justin13888 wants to merge 29 commits into
fix/protocol-headers-every-route-404from
test/e2e-cases-409
Open

[TEST] Land the bounded E2E cases in a capsule-e2e crate#463
justin13888 wants to merge 29 commits into
fix/protocol-headers-every-route-404from
test/e2e-cases-409

Conversation

@justin13888

Copy link
Copy Markdown
Collaborator

Description

Lands the capsule-e2e workspace test crate for issue #409: the real capsule-sdk and a real capsule_core::lifecycle::Workspace driven over TCP against the real capsule-server composition root (boot::assemble under the memory profile, bound to an ephemeral port). Each case is a named test (rg "E2E case N").

Work in progress — this pull request is published at its first coherent commit and grows slice by slice. Cases 2, 3, 6 and 13 follow; then the docs (module-map.md status table, SLICES.md S-Q1–S-Q4).

Related Issues

Closes #409. Stacked on #453 (base branch fix/protocol-headers-every-route-404); sibling-merges #434 and #436.

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

Summary

Validation

In progress; recorded per commit in the final revision of this body.

Risks and rollout

Test-only crate; no runtime code changes outside the merge resolution in capsule-sdk/src/recovery/mod.rs (generated-signature adaptation).

Decisions taken

The lane's decision record, verbatim:

Issue 409 - e2e: land the bounded E2E cases whose server legs now exist (cases 1, 2, 3, 6, 7, 9, 12, 13)
Plan:     v1 (this document)
Branch:   test/e2e-cases-409
Base:     fix/protocol-headers-every-route-404 (head of the W-HEADERS PR #453, 7526395b), stacked; the PR targets that branch; sibling merges: (1) fix/sdk-escrow-route-and-401-retry-408 @ 732967f9 (PR #434), then (2) feat/media-rawshift-still-decode-410 @ 42d21ee5 (PR #436); both by merge commit, in that order
Cause:    -
Touches:  capsule-e2e/** (new), Cargo.toml, Cargo.lock, .github/workflows/ci.yml, mise.toml (comment), capsule-docs/src/content/docs/design/module-map.md (E2E section), SLICES.md (S-Q1-S-Q4, Lane Q intro)
Will not: modify capsule-sdk, capsule-core, capsule-server, capsule-cli source; merge #447/#454; add a container, an env gate, or a nextest group; deliver case 4, 5 (live), 10, 11 code, or the client ceremony halves of 8/12/13; add an external crate
Lane:     serialised behind #453/#434 (+#436 merged); forecast collisions: #419 (ci.yml path filters), #447 (Cargo.toml members, SLICES.md, module-map.md), #454/#405/#412 (SLICES.md)
Settled:  module-map.md:119-161 (the 13 cases, numbering, `rg "E2E case N"`); api-surfaces.md:120-149 (handshake carriage, ten exempt ops, 426 body code); validation.md:137-145 (fail-closed 426); upload-protocol.md:293 (case 2 may be one happy path); download-sync.md:162, authentication.md:228, backup-recovery.md:149, organization.md:209, thumbnails.md:94 (derivatives ride case 2); provenance.md:107-114,131 (chain over records; server chain = provenance blobs served unchanged); PR #434 decision 7 + PR #453 residual l.193 (body-less 413 => code None); boot.rs:1-40 (assemble is the one composition root); AGENTS.md:10 (no mocks between two systems); xtask/src/architecture.rs:116-183 (explicit members)

Decisions taken.

  1. Crate shape: capsule-e2e workspace member (src/lib.rs harness + tests/case_NN_*.rs), in members and default-members
    Taken: A separate package; cargo nextest run --workspace picks it up with no mise.toml change (mise.toml:333). Zero new external crates, so license-check and check_workspace_dependencies are unaffected.
    Rejected: capsule-server/tests/e2e/ — case 1 needs capsule-cli + sea-orm + migration, which would enter the server's dev graph; tests/support/mod.rs (2986 lines) is a doubles fixture, the opposite of the harness wanted; the issue text names the crate. Also rejected: [[test]] per case — cargo autodiscovers tests/*.rs, and nextest already schedules per binary.
    Reverses: nothing.

  2. Harness boots the composition root, never the test-only App
    Taken: Config::load(env, Overrides{memory:true}, Demands::Serve)boot::assembleAssembled::service()kynos::server::Server::bind(("127.0.0.1",0)) (boot.rs:514-533, sdk_client.rs:46-63). Real argon2 accounts, real ProvisionedAuthority, real FilesystemBlobStore, SystemClock. Real SDK, real Workspace; only a temp blob root and temp library roots are controlled.
    Rejected: Fixture::working() (SwallowingBlobs/TestAuthority/ManualClock are doubles, support/mod.rs:2326-2540); spawning the binary (binary.rs:198 already proves the process; stores are then unreachable for case 7's purge and case 2's blob assertion).
    Reverses: nothing.

  3. Sibling merges and conflict resolution
    Taken: Merge [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 first: capsule-sdk/src/client.rs conflicts in build_client/reqwest_client — keep [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434's Client::with_backend(RefreshOn401{inner: ReqwestBackend::new(reqwest_client())}) and make reqwest_client() the [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 OnceLock over [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453's crate::net::http_client() (so the shared client carries the handshake); auth.rs hunks ([FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453:393-400 vs [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 refresh_rejected) are disjoint. Then merge [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436: Cargo.lock (rawshift entries vs [FEAT] Give capsule-server a binary, configuration, operator commands and a serve task #435's three lines), SLICES.md (S-B rows vs S-D rows, disjoint), mise.toml (doc-check hunk vs [FEAT] Give capsule-server a binary, configuration, operator commands and a serve task #435 serve tasks, disjoint), capsule-core/src/lib.rs clean.
    Rejected: skipping [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436 — thumbnails.md:94 makes derivative upload part of case 2, and only [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436 produces a derivative to push; skipping [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 — cases 6/8 need RecoveryClient over the real route and UpgradeClient.
    Reverses: nothing.

  4. Case 1's client leg is the CLI's own sync/list orchestration
    Taken: capsule_cli::remote::sync + remote::list over a sea-orm SQLite migrated with capsule-cli/migration (remote.rs:332-410, 644-649; syncstore.rs:1-15). SLICES.md:113 assigns cases 1–3 to the CLI.
    Rejected: Workspace::apply_remote_entry into capsule_core::db — needs the album AMK on the applying device (sync_apply.rs:28-34) and a decodable AssetManifest on the feed, neither of which a fresh client has.
    Reverses: nothing.

  5. The provenance rung is pushed by the harness as the ProvenanceRecord's canonical CBOR
    Taken: After push_bundle, one UploadClient::upload with BlobRole::Provenance and bytes to_canonical_vec(chain.records().last()), so the server head sha256(blob) (index/memory.rs:377; ops.rs:329-332) equals core's record_hash() (record.rs:18-21; provenance.md:107) and case 7's lifecycle ops chain. Filed as findings: the SDK push ladder omits the rung (push.rs:64-101) so the feed serves manifest_cbor: null for CLI pushes; sync_apply decodes the feed bytes as AssetManifest (sync_apply.rs:187), which will not decode a record.
    Rejected: pushing the bare AssetManifest — server head would be sha256(manifest)record_hash, and every delete op would be a 409 stale-revival (validation.md:61).
    Reverses: nothing.

  6. Case 6 proves the recovered master is A's without a Workspace-from-master seam
    Taken: recover_master_key on the fresh device, MasterKey::from_bytes(k).derive_default_album_id() == a.default_album_id() (master.rs:32-43) plus a.verify_escrow == Verified; assets come back through import_backup(..., exporter_verifying_key) and are checked with read_plaintext + Workspace::verify == Accept. The missing seam (open.rs:241-300 always Account::create()) is filed.
    Rejected: reading A's master bytes — deliberately not exposed (lifecycle/backup.rs:31-36).
    Reverses: nothing.

  7. Case 7 uses signed retention floors instead of a mock clock
    Taken: soft_delete(30) (refused by purge_expired, gc/mod.rs:368-378) and soft_delete(0) (purged), run on assembled.maintenance.collection with Mode::Apply.
    Rejected: injecting a ManualClock — the composition root pins SystemClock (boot.rs:302) and this lane does not modify the server.
    Reverses: nothing.

  8. Case 8 is the server leg through capsule_sdk::upgrade, with the intent signed by core key types
    Taken: Standalone HybridSigningKeys anchored via DirectoryClient, intent built with capsule_core::crypto::upgrade and posted by UpgradeClient::begin; phase/abort via the generated ops. Resume-from-crash stays in the in-process core test (openmls_authority/tests.rs:1461).
    Rejected: signing from a Workspace — its DSK is private (lifecycle/mod.rs:373).
    Reverses: nothing.

  9. Case 9 asserts the 426 twice: per-transport pin and server window
    Taken: UploadError::UpgradeRequired{min,max} from a stale pin (upload.rs:683-684; net.rs:751-753), and SyncError::Rejected{code: Some(error.protocol.version_unsupported)} plus X-Capsule-Protocol-Min/Max from a server booted with PROTOCOL_MIN=PROTOCOL_MAX=2000-01-01 (config.rs:441-450; negotiation.rs:198-206).
    Rejected: only the pin leg — it never exercises ConfigbootNegotiation.
    Reverses: nothing.

  10. The body-less 413 is asserted as code: None
    Taken: RecoveryClient::store_escrow of a 33 MiB WrappedSecretRecoveryError::Malformed{code: None} ([FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 recovery/mod.rs:503-511; limits.rs:63).
    Rejected: asserting error.request.too_largeCodedProblems rewrites only application/problem+json (problem.rs:52-58) and Kynos's BodySize 413 has no body (sync.rs:612-613); PR [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 l.193 and PR [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 decision 7 both say None.
    Reverses: nothing.

  11. Case 11 is [FEAT] Postgres adapters and a conformance suite for four durable ports #447's, in-memory, and needs no Postgres
    Taken: The boundary is the port order, asserted by a fault decorator on AssetIndex ([FEAT] Postgres adapters and a conformance suite for four durable ports #447 tests/support/fault.rs; tests/upload.rs:1087-1260); this lane records the status and does not merge [FEAT] Postgres adapters and a conformance suite for four durable ports #447. The process-restart variant stays deferred as [FEAT] Postgres adapters and a conformance suite for four durable ports #447 states.
    Rejected: re-implementing case 11 in capsule-e2e — the memory composition exposes no fault seam and a second copy would test less.
    Reverses: SLICES.md:5081-5082's "S-C1 crash-injection ≈ case 11" — it is now an exact named test.

  12. Case 12 lands its server leg over the SDK; the client ceremony is blocked
    Taken: reauth → issue → redeem → relay/drain both directions → close, plus the MITM abort at the wire (initiator closes on a mismatched payload; enrollee's drain sees the closed channel, enroll.rs:277-337). Blocked and filed: no safety-code function in the tree, Workspace cannot cross-sign (private IK, lifecycle/mod.rs:367), no second-device bootstrap (open.rs:241); MLS join → W-MEMBER server: server-side album membership (S-C51), which the blob 403 and album writes wait on #405.
    Rejected: claiming case 12 complete.
    Reverses: nothing.

  13. Case 13's server leg drives the exempt guest operations without the handshake
    Taken: A bare reqwest::Client (test code, not a mock) for create_drop/append_drop_chunk proves the exemption in api-surfaces.md:139-149 end to end; the owner side uses the SDK/generated provision_link, list_inbox, adopt_drop.
    Rejected: driving the guest path through the SDK only — it would never show that the exemption holds.
    Reverses: nothing.

  14. Determinism and no live infrastructure
    Taken: FAST_KDF workspaces (64 KiB/t=1, import_round_trip.rs:47-52), fixed passphrases/emails, ConnectionClass::Unmetered, RetryEngine::seeded where a retry engine is constructed, ephemeral ports, temp dirs dropped per test; no CAPSULE_TEST_* gate, nothing in the containers group.
    Rejected: reusing the CLI's DeviceTier::Normal (≈5 s per unlock in debug).
    Reverses: nothing.

  15. Docs: module-map status table; SLICES Lane Q intro rewrite
    Taken: The "suspended for the duration of the Kynos rebuild" sentence (SLICES.md:5071-5075, attributed to the module map, which does not contain it) is replaced with the per-case table; S-Q1 done, S-Q2 done, S-Q3 done, S-Q4 server leg done / client half blocked.
    Rejected: touching ROADMAP.md (no E2E entry).
    Reverses: SLICES.md:5071 "live = 1, 4 (upgraded by S-E5)" — case 4 has no route (planned-modules.txt:20).

  16. CI and task graph
    Taken: ci.yml rust filter += capsule-e2e/**; docs-truth already covers capsule-*/src/**; mise.toml gets a comment only.
    Rejected: a new mise task or nextest override.
    Reverses: nothing.

Decisions taken inside the manifest (appended by the lane):

  1. Sibling merges landed at three SHAs, and the [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 merge needed a semantic resolution beyond client.rs
    Taken: a31fb4f1 merges [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 at 892f7575, 61f54bca merges [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 (732967f9), 0cb2ae1a merges [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453's final 032b6af2, 6b91fbc4 merges [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436 (42d21ee5). [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 regenerated the client, so [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434's RecoveryClient no longer matched the generated escrow operations: every gated operation now takes the protocol date and a header-parameter set, GET /v1/auth/escrow declares a 400, PUT declares a 426. Resolved inside the merge commit by passing PROTOCOL_VERSION and Params::default() (the sync.rs pattern) and mapping the new variants onto RecoveryError::Malformed with the server's code; the two get_quota() test call sites got the same two arguments.
    Rejected: adding a RecoveryError::UpgradeRequired variant — SDK design work, not merge resolution; the code the 426 carries (error.protocol.version_unsupported) reaches the caller through error_code() unchanged.
    Reverses: nothing.

  2. The harness learns the account id from GET /v1/auth/profile and publishes the directory itself
    Taken: The server refuses a directory whose signed core.user_id is not the account's (WrongAccount), and a Workspace mints its own user id with no seam to align them; the server also requires X-Capsule-Identity-Key on every publish and capsule_sdk::directory::DirectoryClient::publish does not send it. The harness builds a DirectoryCore naming the server's account id with the library's own device entry plus a harness-held proposer device, signs it with a harness identity key, and posts it through Session::execute with the header. Both gaps are filed as findings.
    Rejected: DirectoryClient::publish (always 400 against the real server); publishing the Workspace's own directory (always 400 WrongAccount).
    Reverses: nothing.

  3. Case 9's second leg is the first write any client makes — registration — and the window is read off an exempt operation
    Taken: Decision 20 on [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 admits reads at any grammatical date, so a server booted with a 2000-01-01 window cannot mint a session for this build at all (register is a gated write). Leg 2 asserts AuthClient::registerAuthError::Unexpected { status: 426, code: Some(error.protocol.version_unsupported) } and reads X-Capsule-Protocol-Min/Max off GET /v1/version through a bare generated client. Leg 3 asserts a read at 1999-01-01 on the default server succeeds with the window headers and a malformed handshake is 400 error.request.malformed. This amends plan decision 9.
    Rejected: the plan's SyncConsumer::pull expecting 426 (no longer the contract; the generated SyncFeedError has no 426 variant).
    Reverses: plan decision 9's leg 2.

Unresolved review notes

None yet.

… 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
`capsule-core::media` becomes the Capsule-side owner of still detection,
decode, orientation, metadata normalisation and derivative generation,
over `rawshift-image` 0.1.1 from crates.io (a registry dependency, not
the pinned submodule) behind a new `media` feature that `native` implies
and the wasm32 sealing build excludes.

Rawshift owns the codecs; this module owns every decision Capsule has to
make around them:

- the closed sets — `StillFormat` (what counts as a still) and
  `DerivativeFormat` (what a signed `DerivativeManifest.format` may
  say, with the `original` sentinel);
- detection, because the crate's own `detect_standard_format` gates its
  HEIC arm on the HEIC codec, so delegating would make the typed refusal
  for a format depend on whether it can be decoded;
- a pre-decode pixel budget and an unwind boundary, because a pre-1.0
  decoder is fed untrusted bytes on the import path;
- tier sizing and a deterministic integer area-average downscale, since
  the crate has no resize and a derivative's bytes are signed;
- the metadata strip: every encode passes `MetadataEmbedOptions::none()`
  because the crate's default embeds EXIF, GPS included.

Decode covers JPEG, PNG, JXL, TIFF, GIF and WebP; encode covers WebP,
which produces the 256 px q=50 thumbnail tier. HEIC, AVIF, RAW and a
lossy JXL encoder each need a system library or an assembler the cross
and cargo-ndk builds do not carry, so each is a typed
`MediaError::UnsupportedFormat` or a recorded per-format deferral rather
than a silent gap.

`DerivativeCore.format` keeps its `String` type: the same field carries
the `embedding/{model_id}` grammar, and a typed field would turn an
unrecognised value into a parse failure before any signature is
examined. The closed set is enforced at production and at verification
instead.
`lifecycle/import.rs` hard-coded `(exif dimensions, None, DeferredNoCodec)`
for every still, so `capsule_core::lqip` — fully tested since `S-B14` —
had no production caller and `DerivativeStatus` had one reachable value.

`Workspace::prepare_still` replaces the constant triple with one decode
pass that yields the header-derived `content_type`, pixel `dimensions`,
the chromahash `lqip`, and the signed thumbnail derivatives;
`persist_derivatives` writes them under `derivatives/` at the layout the
upload-bundle reader already looks for. Both run inside the existing
signed write path, so nothing about the sealing order moves.

Pixel dimensions win over EXIF because they are post-orientation: a
quarter-turned JPEG's `PixelXDimension` is its *stored* width, which is
transposed relative to what a viewer shows.

Derivatives are persisted **after** the asset's own files are durable,
and a write failure is logged rather than returned: a derivative is
regenerable and must never fail an import whose signed original is
already committed. Nothing here can fail an import over unreadable
pixels — every path degrades to "signed, encrypted, verifiable original,
without a placeholder" and records which reason applied.

`ImportOutcome::Imported` gains `deferred_formats`, summarised by
`ImportExecutionSummary::deferred_format_count()`. It counts *format
variants* missing from assets that do have a thumbnail, where
`deferred_derivative_count()` counts *assets* with none — a decoded JPEG
reports two (the JXL master, the AVIF delivery variant), which is the
number that falls to zero as the encoders land.

The `S-B13` distinction the executor test lost to `S-C59` is observable
again, and now rests on the bytes rather than the extension: a HEIC is
`DeferredNoCodec` (recognised, no codec here, backfillable) while a
`.jpg` that is not a JPEG is `DecodeFailed` (a format we do decode,
failing on these bytes). Both still land as signed, self-verifying
backups.
…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
`capsule_core::lqip` compiled identically on all three surfaces and was
reachable from one: the import pipeline now encodes a placeholder, so the
readers need an entry point or the module's whole reason for living at
the crate root goes unexercised.

- `capsule-wasm`: `decodeLqip` returns `WasmLqipImage` — packed RGBA the
  share viewer hands to `putImageData`, band-limited to the box being
  painted rather than decoded at a fixed size. The whole of the logic
  lives in a pure helper and the boundary is a `map`/`ok_or_else`,
  because `JsError` cannot be constructed off-wasm: a host test reaching
  the error arm through the exported function aborts the test binary
  instead of failing an assertion.
- `capsule-core-ffi`: `render_lqip` → `LqipPlaceholder`. A free function
  rather than a `Catalog` method, deliberately: the `assets` table's
  `chromahash`/`dominant_color` columns are NULL and must stay so until
  `library::rebuild` projects them identically, or a rebuilt index would
  disagree with a freshly written one. So it takes the record the caller
  already holds from the decrypted sidecar rather than pretending the
  index has it.

Both are infallible over a malformed record — an unknown version or a
payload the parser rejects paints the `dominant_color` fill — because a
reader must never misrender a placeholder and a gallery must never fail
to draw a cell over one. The wasm boundary throws only on a
`dominant_color` that is not three bytes, where there is no colour to
fall back to; the FFI paints black, the conventional empty cell.

Both are asserted byte-identical to `Lqip::decode_capped`, which is the
`S-B14` cross-surface criterion at the two boundaries where a second
implementation could have crept in.
`SLICES.md` had S-B1, S-B5 and S-B13 as `RETIRED`/`ready` and S-B14
owing a wasm entry point. Three of the four moved:

- **S-B1** — re-landed on `rawshift-image`; the injected `StillEncoder`
  seam is gone, because it existed only to work around core linking no
  codec. `done*`, owing the JXL master, the AVIF delivery variant, the
  preview tier and HEIC/RAW decode to #437, each blocked on a system
  library or an assembler rather than on a design question.
- **S-B5** — `ACTIVE` and still unimplemented: `rawshift-video` is
  unpublished and the transcode toolchain shares nothing with the still
  path. Owed to #438, with the licensing gate named up front.
- **S-B13** — `done`. There are no stubs to make uninhabited any more:
  the coverage table is a gate checked before any decoder runs, and the
  two-reason distinction is observable again — and now rests on the bytes
  rather than the extension.
- **S-B14** — the owed wasm entry point exists, and so does the FFI one.

`thumbnails.md` gains an implementation-status note under the tier table.
The table stays the contract; the note says what is generated today,
names the toolchain blocking each missing cell, and records that the
distance between the two is a number the import run reports rather than
something a reader has to infer. The "Where LQIP Lives" rationale is
restated on the ground that outlived the teardown: `media` is
`native`-only wherever it exists, so a placeholder every client needs
cannot live inside it and still reach the browser.
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
Two products in `downscale_rgba8` were computed at widths that a
reachable input overflows, both found by re-reading the diff rather than
by a failing test:

- the destination-to-source boundary `(y + 1) * src_h` reaches
  `dst_edge * src_edge`. A 1 x 300000 frame reduced to a 256 px long edge
  makes that 7.7e10, past a 32-bit `usize` — and `armv7-linux-androideabi`
  and `i686-linux-android` are both CI-gated targets;
- the per-channel accumulator was `u32` and reaches `count * 255`, where
  `count` is the whole frame when the function is called with a cap of 1.
  `downscale_rgba8` is a `pub` entry point, so that cap is reachable even
  though the tier table only ever passes 256.

A debug build panics on either; a release build wraps into wrong pixels
or an out-of-bounds index — inside a derivative whose bytes are signed.
Both are now `u64`, with a test at each shape.

Also merges the identical `match` arms clippy's `match_same_arms`
flagged (`standard_format`'s container mapping, `gamut_of`'s sRGB
default) and drops two other lint-level nits. The merged arms lose
nothing: the RAW families map to the container `rawshift-image` actually
sees, which is the same TIFF for all of them, and one wildcard is
honester than an explicit list beside a catch-all with the same body.
`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
Two repairs found after the first push, in the same files.

**The thumbnail tier moves from WebP to JXL, on CI evidence.** WebP was
chosen because `image/webp` is in the tier table and `libwebp` exposes
exactly the q=50 knob the table specifies. It does not compile:
`rawshift-image-0.1.1/src/codecs/webp.rs:164,177,190` pass
`b"EXIF".as_ptr() as *const i8` to `WebPMuxSetChunk`, whose `libwebp-sys`
0.14.4 signature (`ffi.rs:881`) takes `*const core::ffi::c_char` — and
`c_char` is `u8` on aarch64, so it is an E0308 on every 64-bit ARM
target, which is every mobile target Capsule ships. `codecs/mod.rs:13`
compiles that module under `any(webp-decode, webp-encode)`, so
decode-only does not escape it either.

The `webp` feature is therefore dropped and the tier encodes JXL through
the pure-Rust `zune-jpegxl` backend — `image/jxl` is the table's
committed *master* format, so the format that ships first is the one the
table already puts first. The cost is that `JxlSimpleEncoder` is
lossless, so the declared q=50 is advisory and a thumbnail costs more
bytes than intended; a test asserts the losslessness rather than letting
it be discovered. A `cfg(target_arch)` gate was rejected: thumbnails on
desktop and none on any phone is worse than one lossless format
everywhere. `StillFormat::WebP` becomes recognised-but-undecodable, which
is a real user-visible gap for a common export format, so it is filed
rather than absorbed.

**The hardening**, from an adversarial read of the diff:

- the `original` sentinel copied the whole original into
  `derivatives/{uuid}.thumbnail.{ext}`, putting the source's EXIF and GPS
  into a derivative blob and duplicating a file two directories up. The
  contract's word is *references*: a sentinel now carries no bytes and
  its manifest content-addresses the original;
- a derivative-generation failure propagated and failed the whole import,
  trading a missing thumbnail for a missing backup. It is warned and
  reported as `DecodeFailed` instead;
- the unwind boundary covered only `Decoder::decode` while the module
  claimed no codec could abort an import; `media::guarded` now wraps the
  chromahash placeholder and the encode too;
- `capped_dimensions` divided by zero on a zero dimension, reachable
  through a `pub` entry point;
- `MediaMetadata::gamut` claimed to carry the source colour space.
  `probe_standard_image` hard-codes `Srgb` for every format, so it never
  does — documented as the fidelity limitation it is, with `gamut_of`
  kept as the seam;
- `MAX_DECODE_PIXELS`' note counted one buffer at a time and understated
  the peak 3-4x. The real peak is ~2.5 GB, and `native` implies `media`,
  so it lands on a phone: the budget drops to 128 Mpx, still ~25% above a
  102 Mpx medium-format frame;
- the HEIC-detection rationale overstated the crate's blind spot, and
  `encode`'s unreachable arm returned an error naming a `StillFormat`
  that was not at fault.

Three intra-doc links from public items to private ones are also dropped,
so the rustdoc gate passes under `--document-private-items`.
The dependency row, the tier-table status note, `S-B1` and the `AGENTS.md`
sentence all named WebP as the format that ships. They now name JXL, and
each says why WebP is absent — it is a compile failure on every aarch64
target, not a preference, so the reason belongs beside the choice rather
than only in the issue tracker (#444).

The status note gains the honest asterisk on the tier table: the pure-Rust
JXL backend is lossless, so the declared q=50 is advisory and a thumbnail
costs more bytes than the table intends. That is the one place this build
knowingly departs from the contract, and the note says so rather than
leaving a reader to infer it from a byte count.

Decode coverage narrows with the feature: WebP is recognised and refused
alongside HEIC, AVIF and the RAW families, because the crate compiles the
broken module for decode as well as encode.
…cause

The barrel's own module doc explained the fully-qualified `crate::media::…`
links by asserting that a module's documentation is resolved before its
`pub use` items are in scope. That is a guess at rustdoc's resolution
rules, not something this lane verified, and it read as fact.

What was actually observed is the asymmetry: the bare names fail under
the gate (`cargo doc --no-deps`) and resolve under
`--document-private-items`, which is why the failure surfaced only in CI.
The comment now says that, and says the qualified path is used because it
holds either way.
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
Derivative blobs were pushed in the clear. `capsule-sdk::push` shipped
`DerivativeBlob::bytes` verbatim while the original went as ciphertext,
so a field named `ciphertext_hash` addressed plaintext and a thumbnail —
a recognisable low-resolution copy of a private photo — reached the
server readable. Encryption's opening clause admits no exception: "every
asset — original bytes, derivative bytes, metadata blob — is encrypted
client-side", and the upload protocol adds "each encrypted
independently".

`DerivativeCore` gains a **required** `nonce_prefix`, the same type
`ManifestCore` carries. Required rather than `Option` because a receiver
that cannot recover it cannot open the blob at all — an absent prefix
would be an unopenable derivative, not a tolerable gap — and it is safe
to require because no real `derivative-manifest/v1` has ever been
written: derivatives were unconditionally `DeferredNoCodec` until the
decoder landed, and `crate::ml` constructs none. Nothing to stay
compatible with, so the schema string does not move.

Generation encrypts each derivative with the same construction the
original uses — `encrypt_asset_rekey` under the source asset's `file_id`
and the album's AMK, a fresh CSPRNG prefix per derivative — and signs the
**ciphertext's** address. The ciphertext is discarded: the client keeps
the plaintext derivative locally, because that is what the local gallery
paints, and `derivative_blobs` re-derives the ciphertext at push time
from the recorded prefix, exactly as `upload_bundle` already does for the
original. That ordering forced one change in `import_asset_with`: the
original is encrypted *before* derivatives are generated, because the
`original` sentinel is a signed reference to that blob and commits to its
address and prefix, neither of which existed yet.

`media` gains a narrow `DerivativeSealer` seam rather than the AMK: the
codec module still names no key material, and `lifecycle` still names no
codec.

Two further skips at `derivative_blobs`, both previously missing:
`verify_still_format` now runs there, so a still-role manifest naming a
format outside the closed set is the structural rejection the tier table
specifies; and the byte-free `original` sentinel is recognised as an
expected reference and skipped at `debug!`, since an expected absence
logged as a warning is how people learn to ignore warnings. The warning
stays for a non-sentinel manifest whose bytes have gone.

**Also repairs two claims the previous commit made and did not deliver.**
Its message said the unwind boundary had been widened to the placeholder
and the encode, and that a generation failure was reported rather than
propagated. Neither edit actually applied — a silent find-and-replace
miss — and no test covered either path, so both went unnoticed. They are
applied here, and `guarded` is no longer an unused import.
`prepare_still` reached nine parameters when the AMK and the original's
committed pair joined it, and clippy's `too_many_arguments` is right
about what that means here: the signature had grown two *kinds* of input
— the file being imported, and the crypto identity it commits under —
without saying so.

The four file facts (`plaintext`, `ext`, `src`, `exif`) become
`StillSource`. They are one thing, always passed together, and naming
them makes the remaining parameters read as the identity half. Silencing
the lint would have kept the signature and hidden the reason it grew.
…en the guards

Review round 1 findings F1-F16. The two that mattered:

**F1 (critical).** `prepare_still` propagated any derivative-generation
failure as `LifecycleError::Io`, and it did so *before* the asset's files
were written — so an encoder refusing a frame lost the original from the
backup entirely. That contradicted this module's own header, `S-B13`, and
the decision recorded for it. `MediaError` gains a `Sign` variant so a
workspace fault (a hardware signer refusing, a missing epoch key) is
distinguishable at the type level from a codec refusing pixels. Only the
former propagates; every codec, resize and encode failure degrades to
`DerivativeStatus::DecodeFailed` with the real dimensions and placeholder
kept, and the import commits.

**F2 (high).** The unwind boundary guarded only the decode, while the
chromahash placeholder and the JXL encode — both pre-1.0, both running on
the same untrusted pixels — ran bare, so one panicking frame could abort
a twenty-thousand-photo import part way through. Every stage that runs
foreign code over pixels is now guarded, with the stage named so a caught
unwind is attributable. `guarded` is `pub(crate)`: `lifecycle` is its
only caller and no client of this crate has pixels of its own.

The rest:

- **F9** `DerivativeFormat` and `verify_still_format` move to an
  unconditional crate-root module. They were behind the `media` feature,
  which `native` implies — so `capsule-server` and `capsule-wasm`, the two
  crates that *receive* a manifest they did not author, could not link the
  check at all. A closed set only its producer can evaluate is not a
  closed set. `media` re-exports both names.
- **F5** derivative bytes are addressed by `(role, format)`, not by a role
  prefix that took whichever filename sorted first — which would have
  silently skipped both variants the moment AVIF lands beside JXL.
- **F14** a role's chain continues across generation runs instead of
  restarting per invocation, so a backfill extends the record rather than
  forking it.
- **F6** the 32-bit overflow test now genuinely crosses `u32::MAX` (its
  arithmetic was off by 1000x), and the boundary-product claim is
  restated as the defensive measure it actually is.
- **F13** the HEIC executor fixture carries a real `ftyp` header, so the
  test exercises the byte sniffing its own docs claim rather than the
  extension fallback.
- **F11** `decodeLqip` no longer throws on a malformed record: it paints
  the same fallback fill the native FFI paints. One record answered two
  ways by two clients is the divergence `capsule-core::lqip` exists to
  prevent.
- **F12** JPEG/PNG **encoders** move to `[dev-dependencies]`; only the
  fixtures used them, and shipping them put `jpeg-encoder`'s conjunctive
  IJG arm into every release binary. `cargo tree -e normal -i
  jpeg-encoder` is now empty while `cargo deny --all-features` still sees
  it, so the exception stays matched.
- **F7, F8, F10** stale docs: the budget is 128 Mpx in `SLICES.md`, and
  the `libwebp`/"vendored C" claims left over from before the JXL swap
  are corrected.
… media feature

The module moved out of `media` so the receivers can link it, and its
doc comments moved with it — still pointing at `StillFormat`,
`MediaError` and `GeneratedDerivative`, none of which exist in a
`--no-default-features` build. Rustdoc caught it as four unresolved
intra-doc links.

They become prose naming the `media::` path instead of links to it. A
module that exists precisely so a feature-gated stack is not a
prerequisite must not re-acquire that prerequisite through its
documentation.
`FALLBACK_FILL` is a private constant, and `decode_lqip`'s doc linked it —
which resolves only under `--document-private-items` and fails the
rustdoc gate as written. The sentence names the colour instead, which is
what a reader of the public API actually needs to know.
…ature

`pub(crate) use self::decode::guarded` was unconditional, but `lifecycle`
is the only caller and `lifecycle` is `native`-gated. A
`--features media` build without `native` — which the aarch64 cross-check
uses, to isolate the codecs from SQLite's C build — carried it as an
unused import.

Found by that cross-check rather than by `check-rust`, whose clippy pass
runs the default feature set where `native` is on. A feature combination
no gate compiles is a feature combination that rots.
`de756e90` rewrote the `read_derivative_bytes` doc comment by replacing
the tail of the file from that comment onward, and the replacement did
not carry the last two lines with it. `#[cfg(test)] mod tests;` was
deleted, so `lifecycle/upload/tests.rs` stayed tracked, stayed green in
review, and stopped being compiled at all.

Thirteen tests went dark. Nine were the ones that prove this PR's central
claims — the decision-18 KAT that a derivative ships as ciphertext and
decrypts back to the bytes on disk, the tampered-derivative skip, the
sentinel contributing no blob, both arms of the closed-format check, the
missing-bytes skip, the pushed-thumbnail-differs assertion, the
survives-a-reopen case (F3) and the two-formats-by-format case (F5). The
last two were added in the same commit that deleted the declaration, so
they had never been compiled even once. Four more were pre-existing S-D18
coverage that had passed at `4f8b8bda`.

All thirteen pass unmodified against the current
`derivative_blobs(&self, asset, album, epoch)` signature, so the tests
were right and only their declaration was missing.

This is the second time in this branch that a whole-region replacement
silently dropped code — the same failure class recorded for `fe1e3c97`.
The difference is that a lost `mod` declaration cannot be caught by
reading the diff of the file it belongs to: it presents as a passing
suite. `cargo nextest list` is the check that sees it, and its census for
this module now goes in the pull request rather than a summary line.
Review round 2, findings M2-M4 and L5-L10.

**M4 — the reuse refusal now exists.** The encryption doc is normative:
"the writer additionally refuses to emit a `nonce_prefix` it has already
used for that `file_id` … the same rule governs derivative
re-encryption". The sealer passed `replaces: None`, so nothing was ever
refused and the sentence was false for every derivative. It now carries
the set of prefixes already spent on this `file_id` — the original's, plus
every prefix in the existing bundle, which the bundle reader already had
to open — redraws a collision, and adds each sealed prefix before the
next seal. An exhausted draw is `MediaError::Sign`: a 1-in-2^56 collision
eight times running is a broken CSPRNG, which is a workspace fault, and
decision 22 propagates those rather than writing one derivative fewer. A
prefix is folded into the file-key salt, so reusing one reuses the *key*
— two blobs under one keystream, which is what the construction exists to
prevent.

**M2 — an unheld epoch no longer panics the bundle.** `file_key` indexes
`album.amks[&epoch]` with an epoch read off an unverified `.cbor`, inside
a function contracted never to fail. It needs no tampering to reach: an
album recovered from a backup holds only the epochs it escrowed. It is
now the fifth skip reason, and the rustdoc enumerates five.

**M3 — embedding-role manifests are named out of scope.** `F5` keyed the
reader by `(role, format)`, and `embedding/{model_id}` parses to no still
format, so every embedding manifest fell through to "no bytes on disk" —
a misleading warning for an artefact with no writer, since `crate::ml`
produces none. They are skipped at `debug!` and the doc says why.

**L9/L10 — the two failure paths are tested through the real import.**
The previous F2 test called `guarded` itself, so deleting every
production call site left it green. Both now drive a fault through
`Workspace::import_asset_with` via a `#[cfg(test)]` hook inside the
sealer — absent from a release build, not merely disabled — and assert
what actually matters: `DecodeFailed` reported, and the original
committed, signed and self-verifying, with real dimensions and a real
placeholder. Both were mutation-checked: reverting the match arm to `?`
fails the first; removing the guard aborts the second.

L5-L8 are doc corrections: the closed set is named at
`crate::derivative_format` and described as linkable without `media`; the
two `# Errors` blocks route sealing to `Sign`; the `{uuid}.{role}.`
prefix-scan description is replaced by the exact-path composition that
superseded it; and two WebP leftovers in the tests.
Both `# Errors` blocks in `media::derivative` still routed signing and
sealing failures to `MediaError::Encode`. That has been untrue since the
`Sign` variant was introduced: `sign_derivative` returns `Sign` for a
signer refusal and for a manifest that will not serialise, and the only
`DerivativeSealer` implementation returns `Sign` both when the encryption
refuses and when it cannot draw an unused nonce prefix inside its retry
budget.

The distinction is the contract, not bookkeeping, which is why a stale
doc here is worth a commit of its own: the import path **degrades** an
`Encode` or `ZeroDimension` to "this asset has no thumbnail" and commits
the original anyway, and **propagates** `Sign`, because a workspace that
cannot author a signed record is broken in a way a missing derivative is
not. A reader following the old text would have concluded the two were
interchangeable.

The trait block also drops its reference to "a drawn prefix that collides
with the one being replaced": `replaces` is always `None` for a
derivative, which supersedes nothing. Non-reuse is enforced against the
set of prefixes already spent on that `file_id` instead.

Documentation only; no behaviour change.

Recorded because it is the third instance on this branch: these two edits
were claimed in `5a486852`'s message and never landed. A batch script
computed several replacements against one file and wrote once at the end,
an `assert` on a later pattern aborted it, and every earlier in-memory
edit to that file was discarded while an earlier *file*'s write had
already succeeded — so the per-edit progress output looked like success.
Each edit here was written and read back separately.
…ases-409

Resolves capsule-sdk/src/client.rs by keeping the shared OnceLock
transport and building it from crate::net::http_client(), so the
generated operations carry the protocol handshake. Adapts the escrow
client and the get_quota test calls to the regenerated signatures:
every gated operation now takes the protocol date and a header
parameter set, GET escrow declares a 400, and PUT escrow declares a
426, mapped to Malformed with the server's code.
A workspace test crate that boots the real server composition root
(boot::assemble under the memory profile) on an ephemeral port and drives
the real SDK and a real library Workspace against it. The harness adds
the provenance rung the SDK's push ladder omits and publishes the device
directory with the identity-key header the SDK's client does not send.

Cases landed as named tests: 1 (CLI sync and list over SQLite), 7
(lifecycle chain and retention-honouring purge), 8 (upgrade ceremony,
server leg), 9 (protocol gate: stale pin, out-of-window server, read
admission) and 12 (enrollment relay, server leg), plus the body-less
413 contract.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: f3d0bad
Status: ✅  Deploy successful!
Preview URL: https://51e5be82.capsule-22k.pages.dev
Branch Preview URL: https://test-e2e-cases-409.capsule-22k.pages.dev

View logs

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