[TEST] Land the bounded E2E cases in a capsule-e2e crate - #463
Open
justin13888 wants to merge 32 commits into
Open
[TEST] Land the bounded E2E cases in a capsule-e2e crate#463justin13888 wants to merge 32 commits into
justin13888 wants to merge 32 commits into
Conversation
… 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
…route-and-401-retry-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.
Deploying capsule with
|
| Latest commit: |
1043e5c
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f699c64d.capsule-22k.pages.dev |
| Branch Preview URL: | https://test-e2e-cases-409.capsule-22k.pages.dev |
This was referenced Sep 5, 2026
Open
Case 2 pushes a real library import through the SDK ladder and the provenance rung and asserts every blob byte-equal at its content address, storage-verify durable and the asset on the feed. Case 3 pulls that feed from a second session at cursor zero and fetches the metadata blob and the original by address. Case 6 escrows through the recovery client, recovers the master key on a fresh device, restores the backup and walks the chain. Case 13 deposits a sealed drop through the two exempt guest operations with no handshake, adopts it in the library and on the server and proves the original durable. The fixture gains a sized still; the harness imports arbitrary bytes and projects envelopes from the head manifest core rather than an upload bundle. Each case names the issue that bounds it (#464-#471).
The paths filter gains capsule-e2e/**; the test-rust task comment names the crate the workspace run now includes.
The module map's E2E section gains a status table naming each case's test, its state and the issue holding the rest of its wording. Lane Q's intro drops the stale "suspended for the Kynos rebuild" note, and S-Q1 to S-Q4 flip with their landed tests and owed seams.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Lands the
capsule-e2eworkspace test crate for issue #409: the realcapsule-sdkand a realcapsule_core::lifecycle::Workspacedriven over TCP against the realcapsule-servercomposition root (boot::assembleunder the memory profile, bound to an ephemeral port; real argon2 accounts, the real provisioned authority, a real filesystem blob store, the system clock). No container, no environment gate;cargo nextest run --workspacepicks it up. Each case is a named test (rg "E2E case N").Head:
1043e5ca55c12d08086f89902517be7b08263cc2.Related Issues
Closes #409. Stacked on #453 (base branch
fix/protocol-headers-every-route-404); sibling-merges #434 and #436.Findings filed by this lane (each named in the test that hits it and in
SLICES.md/ the module map): #464 (SDK push ladder omits the provenance rung), #465 (coresync_applydecodes record bytes as a manifest), #466 (SDK directory publish omitsX-Capsule-Identity-Key), #467 (noWorkspaceseam to open as a server account / from a recovered master key), #468 (backup artifact carries no album authority, so a restore does notverify), #469 (DropAdopter::adoptregisters nothing to publish), #470 (upload policy refusesimage/jxl, the thumbnail format the media stack now emits), #471 (no cross-sign / safety-code seam for the enrollment client half).Contributor Checklist
Summary
capsule-e2e/(new): harness (Server,Device, the provenance rung, the directory publish, the byte-built JPEG fixture) and one test file per case.Cargo.toml/Cargo.lock:capsule-e2einmembersanddefault-members; no new external crate..github/workflows/ci.yml:rustfilter +=capsule-e2e/**;mise.toml: comment ontest-rust.module-map.md: E2E status table;SLICES.md: Lane Q intro, S-Q1–S-Q4 rows and blocks.892f7575(a31fb4f1) then032b6af2(0cb2ae1a); [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 at732967f9(61f54bca, with theclient.rsresolution and a semantic fix-up ofrecovery/mod.rsplus twoget_quotatest call sites for the regenerated signatures); [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436 at42d21ee5(6b91fbc4, clean).Per-case status (wall time from the nextest summary, debug build)
case_01_auth_sync_query.rscase_02_import_upload_finalize.rscase_03_sync_pickup.rs(client half; server halfcapsule-server/tests/sync.rs)case_06_backup_restore.rsverifyasserted as refusedcase_07_lifecycle.rscase_08_upgrade_ceremony.rsprotocol_contract.rs(three legs)case_12_enrollment.rs(two tests)case_13_web_drop_adopt.rsprotocol_contract.rscode: None)Validation
All inside the worktree, foreground, after the host deleted
target/(CARGO_TARGET_DIR=/var/tmp/capsule-lane-409/target):cargo nextest run -p capsule-e2e— pass, 13/13 (summary 28.1 s).cargo nextest list -p capsule-e2e— 13 tests (count never dropped across commits).cargo nextest run --workspace— pass, 1914/1914 (1901 before this crate + 13).cargo nextest run -p capsule-core --features ffi— pass, 785/785;cargo nextest run -p capsule-sdk --features ffi— pass, 179/179 (the threetest-rustinvocations, run individually).mise run check-rustsub-tasks, run individually:format-check-rustpass;lint-check-rustpass;i18n-checkpass;i18n-guardpass;openapi-check-kynospass;architecture-checkpass;license-checkpass;translate-readme-checkpass;build-rustpass;build-check-wasmpass;build-ffipass;lint-check-ffipass;gen-bindingspass;verify-examplespass (16/16).doc-check-rust— fail, pre-existing:capsule-core/src/media/derivative.rs:20has an unresolved intra-doc link toverify_still_format; the file is [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436's and untouched here (the repair belongs to that PR).cargo clippy -p capsule-e2e --all-targets -- $CLIPPY_FLAGS(the gate lints lib targets only; this widened it to the tests) — pass.mise run check-docs-truth— pass (473 links, 94 endpoint citations, 119 module paths).mise run check-md— pass (168 files, 0 issues).mise run check-docs(afterbun install --frozen-lockfile) — pass (59 pages built, links valid).mise run check-commits fix/protocol-headers-every-route-404— no errors in 4 commits.cargo nextest run -p capsule-sdk -p capsule-server933/933 (after [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434);cargo nextest run --workspace1901/1901 (after [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436).Risks and rollout
Test-only crate; the only non-test source change is the merge resolution in
capsule-sdk/src/recovery/mod.rs(generated-signature adaptation, mapping the new400/426variants ontoRecoveryError::Malformedwith the server's code). Case 6 is the slowest case (two Argon2id passes atDeviceTier::LowRam, 28 s debug) — inside nextest's 60 s slow period but worth watching on slower CI runners.Decisions taken
The lane's decision record, verbatim:
Decisions taken.
Crate shape:
capsule-e2eworkspace member (src/lib.rs harness + tests/case_NN_*.rs), inmembersanddefault-membersTaken: A separate package;
cargo nextest run --workspacepicks it up with nomise.tomlchange (mise.toml:333). Zero new external crates, so license-check andcheck_workspace_dependenciesare unaffected.Rejected:
capsule-server/tests/e2e/— case 1 needscapsule-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 autodiscoverstests/*.rs, and nextest already schedules per binary.Reverses: nothing.
Harness boots the composition root, never the test-only App
Taken:
Config::load(env, Overrides{memory:true}, Demands::Serve)→boot::assemble→Assembled::service()→kynos::server::Server::bind(("127.0.0.1",0))(boot.rs:514-533, sdk_client.rs:46-63). Real argon2 accounts, realProvisionedAuthority, realFilesystemBlobStore,SystemClock. Real SDK, realWorkspace; 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.
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.rsconflicts inbuild_client/reqwest_client— keep [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434'sClient::with_backend(RefreshOn401{inner: ReqwestBackend::new(reqwest_client())})and makereqwest_client()the [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434OnceLockover [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453'scrate::net::http_client()(so the shared client carries the handshake);auth.rshunks ([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 #434refresh_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.rsclean.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
RecoveryClientover the real route andUpgradeClient.Reverses: nothing.
Case 1's client leg is the CLI's own sync/list orchestration
Taken:
capsule_cli::remote::sync+remote::listover a sea-orm SQLite migrated withcapsule-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_entryintocapsule_core::db— needs the album AMK on the applying device (sync_apply.rs:28-34) and a decodableAssetManifeston the feed, neither of which a fresh client has.Reverses: nothing.
The provenance rung is pushed by the harness as the ProvenanceRecord's canonical CBOR
Taken: After
push_bundle, oneUploadClient::uploadwithBlobRole::Provenanceand bytesto_canonical_vec(chain.records().last()), so the server headsha256(blob)(index/memory.rs:377; ops.rs:329-332) equals core'srecord_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 servesmanifest_cbor: nullfor CLI pushes;sync_applydecodes the feed bytes asAssetManifest(sync_apply.rs:187), which will not decode a record.Rejected: pushing the bare
AssetManifest— server head would besha256(manifest)≠record_hash, and every delete op would be a 409 stale-revival (validation.md:61).Reverses: nothing.
Case 6 proves the recovered master is A's without a Workspace-from-master seam
Taken:
recover_master_keyon the fresh device,MasterKey::from_bytes(k).derive_default_album_id() == a.default_album_id()(master.rs:32-43) plusa.verify_escrow == Verified; assets come back throughimport_backup(..., exporter_verifying_key)and are checked withread_plaintext+Workspace::verify == Accept. The missing seam (open.rs:241-300 alwaysAccount::create()) is filed.Rejected: reading A's master bytes — deliberately not exposed (lifecycle/backup.rs:31-36).
Reverses: nothing.
Case 7 uses signed retention floors instead of a mock clock
Taken:
soft_delete(30)(refused bypurge_expired, gc/mod.rs:368-378) andsoft_delete(0)(purged), run onassembled.maintenance.collectionwithMode::Apply.Rejected: injecting a
ManualClock— the composition root pinsSystemClock(boot.rs:302) and this lane does not modify the server.Reverses: nothing.
Case 8 is the server leg through
capsule_sdk::upgrade, with the intent signed by core key typesTaken: Standalone
HybridSigningKeys anchored viaDirectoryClient, intent built withcapsule_core::crypto::upgradeand posted byUpgradeClient::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.
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), andSyncError::Rejected{code: Some(error.protocol.version_unsupported)}plusX-Capsule-Protocol-Min/Maxfrom a server booted withPROTOCOL_MIN=PROTOCOL_MAX=2000-01-01(config.rs:441-450; negotiation.rs:198-206).Rejected: only the pin leg — it never exercises
Config→boot→Negotiation.Reverses: nothing.
The body-less 413 is asserted as
code: NoneTaken:
RecoveryClient::store_escrowof a 33 MiBWrappedSecret→RecoveryError::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_large—CodedProblemsrewrites onlyapplication/problem+json(problem.rs:52-58) and Kynos'sBodySize413 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 sayNone.Reverses: nothing.
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-C1crash-injection ≈ case 11" — it is now an exact named test.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,
Workspacecannot 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.
Case 13's server leg drives the exempt guest operations without the handshake
Taken: A bare
reqwest::Client(test code, not a mock) forcreate_drop/append_drop_chunkproves the exemption in api-surfaces.md:139-149 end to end; the owner side uses the SDK/generatedprovision_link,list_inbox,adopt_drop.Rejected: driving the guest path through the SDK only — it would never show that the exemption holds.
Reverses: nothing.
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::seededwhere a retry engine is constructed, ephemeral ports, temp dirs dropped per test; noCAPSULE_TEST_*gate, nothing in thecontainersgroup.Rejected: reusing the CLI's
DeviceTier::Normal(≈5 s per unlock in debug).Reverses: nothing.
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).
CI and task graph
Taken: ci.yml
rustfilter +=capsule-e2e/**; docs-truth already coverscapsule-*/src/**;mise.tomlgets a comment only.Rejected: a new mise task or nextest override.
Reverses: nothing.
Decisions taken inside the manifest (appended by the lane):
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.rsTaken:
a31fb4f1merges [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 at892f7575,61f54bcamerges [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 (732967f9),0cb2ae1amerges [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453's final032b6af2,6b91fbc4merges [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'sRecoveryClientno longer matched the generated escrow operations: every gated operation now takes the protocol date and a header-parameter set,GET /v1/auth/escrowdeclares a400,PUTdeclares a426. Resolved inside the merge commit by passingPROTOCOL_VERSIONandParams::default()(thesync.rspattern) and mapping the new variants ontoRecoveryError::Malformedwith the server's code; the twoget_quota()test call sites got the same two arguments.Rejected: adding a
RecoveryError::UpgradeRequiredvariant — SDK design work, not merge resolution; the code the 426 carries (error.protocol.version_unsupported) reaches the caller througherror_code()unchanged.Reverses: nothing.
The harness learns the account id from
GET /v1/auth/profileand publishes the directory itselfTaken: The server refuses a directory whose signed
core.user_idis not the account's (WrongAccount), and aWorkspacemints its own user id with no seam to align them (core: a Workspace cannot open as a server account — no constructor from a recovered master key, no way to bind the account id #467); the server also requiresX-Capsule-Identity-Keyon every publish andcapsule_sdk::directory::DirectoryClient::publishdoes not send it (sdk: DirectoryClient::publish omits X-Capsule-Identity-Key, so every publish is a 400 against the real server #466). The harness builds aDirectoryCorenaming 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 throughSession::executewith the header.Rejected:
DirectoryClient::publish(always400against the real server); publishing theWorkspace's own directory (always400 WrongAccount).Reverses: nothing.
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-01window cannot mint a session for this build at all (registeris a gated write). Leg 2 assertsAuthClient::register→AuthError::Unexpected { status: 426, code: Some(error.protocol.version_unsupported) }and readsX-Capsule-Protocol-Min/MaxoffGET /v1/versionthrough a bare generated client. Leg 3 asserts a read at1999-01-01on the default server succeeds with the window headers and a malformed handshake is400 error.request.malformed. This amends plan decision 9.Rejected: the plan's
SyncConsumer::pullexpecting426(no longer the contract; the generatedSyncFeedErrorhas no 426 variant).Reverses: plan decision 9's leg 2.
Envelopes are projected from the head
ManifestCore, not fromupload_bundleTaken:
capsule_e2e::push::{sdk_envelope, wire_envelope}make the same projectioncapsule_sdk::push::envelope_formakes, from the head record's core.upload_bundlere-derives the original's ciphertext, which a wrapped-key (adopted) asset cannot do and a tombstone head need not; the lifecycle op carries the head record's canonical CBOR plus the sealed metadata blob fromAssetStatewhenever the head binds one.Rejected:
upload_bundleper op (fails on wrapped keys; heavier than the op needs).Reverses: nothing.
Case 2 runs on the 8×8 still; the large still reproduces server: the upload policy's closed content-type set omits image/jxl, refusing every thumbnail the media stack now encodes #470 and waits for it
Taken: A still inside the thumbnail cap gets the byte-free
originalsentinel, so the ladder is T0 then T2 and the case asserts exactly that.fixtures::large_synthetic_jpeg(512×512) makes the media stack encode a real JXL thumbnail, whose T1 session the server refuses (error.upload.unsupported_content_type: the closed set has noimage/jxl) — filed as server: the upload policy's closed content-type set omits image/jxl, refusing every thumbnail the media stack now encodes #470; the fixture stays so the case can switch when it closes.Rejected: asserting the refusal in a named E2E case (a test pinning a defect); dropping the fixture.
Reverses: plan's "post-[FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436, one derivative ref" expectation for case 2.
Case 6 asserts the restore as far as the tree allows and pins the missing authority
Taken: The recovered master key is proved A's by deriving A's default album id; the restored library reads the asset byte for byte,
has_album, andProvenanceChain::verify_walkholds;Workspace::verifyis asserted to refuse withNotFound("authority …")(core: the backup artifact carries no album authority, so a restored asset reads but does not verify #468).verify_escrow(a third Argon2id pass, A's own offline check already covered in core) is dropped to keep the case at two passes.Rejected:
VerifyOutcome::Accept(impossible without the album authority);DeviceTierbelowLowRam(the brief pins it).Reverses: plan decision 6's
verify_escrowandAcceptassertions.Case 13's server leg stops at the durable adopted original
Taken: The library's adopt returns a signed wrapped-key manifest but registers no asset and discards the sealed metadata blob (core: DropAdopter::adopt returns the manifest but registers no asset and discards the sealed metadata blob #469), so the owner has nothing to publish for the index tier; the case asserts the server adoption, the emptied inbox, storage-verify durable for the original, and that the asset is not yet on the feed. The guest leg runs through a bare generated
rest::Client::new— no session, no default headers — which proves the exemption with a Spargen client rather than a hand-writtenreqwest.Rejected: uploading a provenance rung without the metadata blob (invariant 25 refuses a hash without its bytes); hand-writing the guest requests.
Reverses: plan decision 13's "feed entry present".
Case 3 fetches the metadata blob by the entry's own address, whole
Taken: The feed's blob list carries only
originalandderivativeroles; the metadata blob is named byentry.metadata_blob(its content address) with no size, so B callsBlobSource::get_range(hash, 0, None)and assertsComplete. The original is fetched by address and declared size throughfetch_blob.Rejected: reading the size from A's bundle (B would not know it).
Reverses: plan's "find
metadatainblobs.derivatives".Unresolved review notes
doc-check-rustfails on [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436'scapsule-core/src/media/derivative.rs:20(unresolved intra-doc link); not this lane's file.