Skip to content

[FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats - #436

Open
justin13888 wants to merge 17 commits into
chore/freeze-capsule-core-api-399from
feat/media-rawshift-still-decode-410
Open

[FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats#436
justin13888 wants to merge 17 commits into
chore/freeze-capsule-core-api-399from
feat/media-rawshift-still-decode-410

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

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 behind a new media feature that native implies and the wasm32-unknown-unknown sealing build excludes. The fully tested capsule-core::lqip module gains its first production caller, and DerivativeStatus gets more than one reachable value for the first time since S-C59.

Summary

The module (capsule-core/src/media/, private submodules behind one barrel). Rawshift owns codecs; this owns every decision Capsule has to make around them:

  • detectStillFormat, the closed set of stills Capsule models (JPEG, PNG, WebP, JXL, TIFF, GIF, Netpbm, AVIF, HEIC and the six RAW families), with a Capsule-owned magic-byte table, an extension fallback used in exactly two places a header cannot settle, and is_decodable() as the single codec-coverage predicate.
  • decode — the Decoder seam: sniff, gate, budget, decode, apply the EXIF orientation, normalise to packed opaque RGBA8. guarded puts an unwind boundary around every step that runs foreign code over pixels.
  • resize — a deterministic integer area-average downscale, because the crate has no resize and a derivative's bytes are signed.
  • derivativeDerivativeFormat (with the original sentinel), DerivativeTier, generate_still_derivatives, verify_still_format, and the two-signature manifest chain.
  • errorMediaError, including UnsupportedFormat { format, op }.

Import wiring. Workspace::prepare_still decodes once per still and yields the header-derived content_type, pixel dimensions (post-orientation, so a quarter-turned JPEG's pair is transposed relative to its EXIF tags), the chromahash lqip, and the signed thumbnail derivatives. persist_derivatives writes them at the layout the upload-bundle reader already looks for, after the asset's own files are durable, logging rather than returning on failure — a derivative is regenerable and must never fail an import whose signed original is already committed.

Per-format deferral. 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 and the AVIF delivery variant — which is the number that falls to zero as #437 lands.

The S-B13 distinction 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.

Exports. capsule-wasm's decodeLqip and capsule-core-ffi's render_lqip, both asserted byte-identical to Lqip::decode_capped — the S-B14 cross-surface criterion at the two boundaries where a second implementation could have crept in.

Why JXL is the format that ships first — and why it is not WebP

WebP was the first choice, for good reasons: image/webp is an admitted value in the tier table, LibwebpEncodeConfig exposes exactly the q=50 knob the table specifies, and libwebp-sys is MIT with pre-generated bindings and a vendored static build.

CI refuted it. 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. c_char is u8 on aarch64, so the cast is an E0308 on every 64-bit ARM target — which is every mobile target Capsule ships — and the Apple leg additionally fails at link time. codecs/mod.rs:13 compiles that module under any(webp-decode, webp-encode), so decode-only does not escape it. The run may not patch, vendor, or fork a dependency.

So 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, and every enabled codec is now pure Rust — no C links at all, which is what keeps the mobile cross-builds clean. The cost is that JxlSimpleEncoder is lossless, so the declared q=50 is advisory and a thumbnail costs more bytes than the table intends; the_jxl_thumbnail_is_lossless_today asserts that rather than leaving it to be discovered.

Rejected: gating WebP behind cfg(target_arch) — thumbnails on desktop and none on any phone is worse than one lossless format everywhere. Rejected: JPEG, which is not in the format table.

The consequence beyond the encoder is that StillFormat::WebP is recognised and not decodable, because the broken module is shared by both directions. WebP is a common export format, so that is a real user-visible gap and is filed (#444) rather than absorbed. A lossy JXL still needs C libjxl and AVIF still needs nasm (#437).

Privacy: three separate holes, all closed

  1. The encode path. rawshift-core's MetadataEmbedOptions::default() is all(), so a default-configured encode copies the source's EXIF — GPS included — into the thumbnail. Every Capsule encode passes MetadataEmbedOptions::none() and hands the encoder ImageMetadata::default(), so the source's metadata is never read at all. A test demonstrates the leak using the crate's own default and then asserts Capsule's derivative carries no EXIF/XMP/ICCP chunk and none of the source's GPS rationals.
  2. Derivative blobs crossed the network in the clear (found by the same read; fixed in the repair round). capsule-sdk::push shipped DerivativeBlob::bytes verbatim while the original went as ciphertext, so a field named ciphertext_hash addressed plaintext. Derivatives are now encrypted with the same construction the original uses — a fresh per-derivative nonce prefix, signed into the manifest — and re-derived at push from the plaintext the library keeps locally. The read half is filed (media: decrypt fetched derivative blobs — capsule_sdk::fetch::open_representation returns ciphertext with no key path #448).
  3. The original sentinel (found by an adversarial read of this diff, after the first push). The retired implementation copied the whole original into derivatives/{uuid}.thumbnail.{ext} whenever the source was inside the 256 px cap — putting the original's EXIF and GPS into a derivative blob, which is the one place a re-encoded thumbnail is metadata-free by construction. The contract's word is references, so a sentinel now carries no bytes: its manifest content-addresses the original the holder already has, and nothing is written beside it. Three tests pin the absence.

Validation

Run inside the worktree at /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-media-rawshift-still-decode-410.

Command Outcome
cargo test -p capsule-wasm pass — 8 tests
cargo test -p capsule-core-ffi pass — 18 tests
cargo test -p capsule-cli --test import_round_trip pass — 3 tests, the CLI end-to-end import
mise run test-rust TEST_RUST=0 at 5a4868521764 tests run: 1764 passed, 0 skipped, then 785 passed, then 160 passed: 2709 tests, 0 failures across the workspace, capsule-core --features ffi and capsule-sdk --features ffi. The count rose by 44 from the previous round, which is the thirteen tests C1 restored plus this round's new coverage.
mise run check-rust CHECK_RUST=0 at 5a486852. All fourteen sub-tasks ran, ending at verify-examples: format-check-rust, lint-check-rust, doc-check-rust, i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, translate-readme-check, build-rust, build-check-wasm, build-ffi, lint-check-ffi, gen-bindings, verify-examples.
mise run license-check passlicenses ok, re-run after libwebp-sys left the graph. zune-jpegxl 0.5.2 is MIT OR Apache-2.0 OR Zlib, already allow-listed, so deny.toml and NOTICE need no change: the MPL list still names rawshift-core/rawshift-image, and jpeg-encoder's IJG exception is still matched.
mise run build-check-wasm pass
cargo tree -p capsule-core --target wasm32-unknown-unknown --no-default-features -e normal -i rawshift-image WASM=0, emptywarning: nothing to print. -e normal is now the honest form of this check: decision 27 put the JPEG/PNG encoders in [dev-dependencies], so the default edge set shows a dev-only path to rawshift-image that no wasm artefact links. The shipping graph is clean.
mise run build-ffi pass
mise run gen-bindings passsurface check passed
mise run check-docs-truth pass at 5a486852cross-links: 473 link(s) checked, all resolve. / endpoint-census: 84 citation(s) checked, all resolve. / module-paths: 119 path(s) checked, all resolve.
mise run check-md pass at 5a486852Linting: 168 files / Summary: 0 issues in 0 files
mise run check-docs pass at 5a48685259 page(s) built, Complete! (after bun install in capsule-docs/; see below)
cargo check -p capsule-core --no-default-features --features media pass — the codec stack alone, with no SQLite/C in the graph
cargo check -p capsule-core --no-default-features --features media --target aarch64-linux-android ARM=0 at 5a486852 — the local proof for the codec swap: the JXL-only codec set compiles for a real aarch64 target, the same c_char = u8 platform WebP fails on. native is off so no C build script is in the graph, which isolates the codecs from rusqlite/bundled. (The target was added with rustup target add; mise run targets-add is the repo's own sanctioned way to do that.)
mise run doc-check-rust DOC_CHECK_RUST=0 at 42d21ee5. This is the gate that made Rust (fmt + clippy + build) red at 72e5921; see the diagnosis below.
cargo nextest run -p capsule-core --features media NEXTEST=0 at 42d21ee5780 tests run: 780 passed, 0 skipped. Re-run for 42d21ee5 (docs(core): say which failures are Sign and which are Encode), which corrects the two # Errors blocks in media::derivative that still routed signing and sealing failures to MediaError::Encode. Documentation only, no behaviour change.
capsule import smoke on a temp dir pass — a 512x384 PNG through the real CLI binary: imported: 1, errors: 0, generated: 1, deferred: 2, and on disk derivatives/{uuid}.thumbnail.jxl (35812 bytes, magic ff 0a — a real bare JXL codestream) beside a signed {uuid}.derivatives.cbor, with the sidecar carrying lqip/chromahash. This is the plan's acceptance criterion, end to end.

Failures classified

  • Rust (fmt + clippy + build) red at 72e5921caused, and fixed. The cause is doc-check-rust, not the install-action noise higher in the log: cargo doc --no-deps failed with three unresolved intra-doc links in capsule-core/src/media/mod.rs (StillFormat, DerivativeFormat, MediaError::UnsupportedFormat) plus one private-item link in lifecycle/import.rs:315. The observable fact — stated without guessing which of rustdoc's resolution rules produces it — is an asymmetry: a bare [`StillFormat`] in media/mod.rs does not resolve under cargo doc --no-deps, and does resolve when the same command is given --document-private-items. That is exactly why it never reproduced locally: my pre-emptive runs used the private-items form, anticipating the base branch's gate change. All four links now use their full crate::media::… path, which holds under both, and the module records the asymmetry rather than a theory about it. mise run doc-check-rust now exits 0.
  • Rust cross (android) / (apple) / (linux-arm64) red at 72e5921caused, and fixed by the codec swap above. Verified in-tree against the unpacked crate sources rather than taken from the log: rawshift-image-0.1.1/src/codecs/webp.rs:164,177,190 vs libwebp-sys-0.14.4/src/ffi.rs:881, and codecs/mod.rs:13 gating the module on any(webp-decode, webp-encode).
  • Build & test Capsule.app red at 72e5921caused, same dependency, failing at link time (symbol(s) not found for architecture arm64) rather than compile time.
  • mise run check-docs first failed at build-docs with astro: command not foundunavailable, not caused: node_modules had never been installed in this worktree. bun install --frozen-lockfile in capsule-docs/ provisioned it (gitignored, tree still clean) and the gate then passed.
  • cargo check -p capsule-core --target aarch64-apple-iosunavailable on this Linux host: error occurred in cc-rs: failed to find tool "xcrun", from rusqlite/bundled's C build, not from any media crate. The aarch64-linux-android check above is the substitute, and it isolates the question better anyway.
  • doc-check-rust on the merged tree: pass. An earlier draft of this section claimed a set of rustdoc findings were "pre-existing at 718bc82" — that cited the commit before the base repaired those links, so the claim was not evidence of anything. It is replaced by the gate's own result on this head: mise run doc-check-rust exits 0, and it runs inside the check-rust result recorded above.
  • The one early mise run build-ffi failure was caused and self-inflicted: it ran against a tree mid-edit.

lifecycle::upload:: test census. The confirming round found that de756e90 had deleted this module's #[cfg(test)] mod tests;, so thirteen tests were tracked but never compiled — nine of them the ones proving this PR's central claims, two of which had never been compiled even once. Restored in fc7a6d17; all thirteen then passed unmodified. Verbatim from cargo nextest list -p capsule-core --features media, filtered to this module — 15 tests:

capsule-core lifecycle::upload::tests::a_derivative_naming_an_unheld_epoch_is_skipped_rather_than_panicking
capsule-core lifecycle::upload::tests::a_derivative_survives_a_reopen_and_still_reaches_the_bundle
capsule-core lifecycle::upload::tests::a_derivative_that_cannot_be_persisted_never_costs_the_original
capsule-core lifecycle::upload::tests::an_embedding_role_manifest_is_out_of_scope_and_skipped
capsule-core lifecycle::upload::tests::a_non_sentinel_manifest_with_no_bytes_is_still_skipped
capsule-core lifecycle::upload::tests::a_still_role_derivative_outside_the_closed_set_is_skipped
capsule-core lifecycle::upload::tests::a_tampered_derivative_is_skipped_rather_than_shipped
capsule-core lifecycle::upload::tests::derivative_blobs_ship_ciphertext_that_decrypts_to_the_bytes_on_disk
capsule-core lifecycle::upload::tests::export_backup_still_round_trips_through_the_accessor
capsule-core lifecycle::upload::tests::the_original_sentinel_contributes_no_blob_and_is_not_an_error
capsule-core lifecycle::upload::tests::the_pushed_thumbnail_is_not_the_jxl_on_disk
capsule-core lifecycle::upload::tests::two_formats_for_one_role_are_addressed_by_format_not_by_filename_order
capsule-core lifecycle::upload::tests::upload_bundle_ciphertext_matches_the_manifest_hash
capsule-core lifecycle::upload::tests::upload_bundle_rejects_an_unknown_asset
capsule-core lifecycle::upload::tests::upload_bundle_survives_a_reopen

Of these, derivative_blobs_ship_ciphertext_that_decrypts_to_the_bytes_on_disk is the decision-18 KAT: plaintext → encrypted at import → manifest → derivative_blobs → the blob's bytes are the re-derived ciphertext, its hash is the signed ciphertext_hash, and decrypting with the recorded nonce_prefix returns the on-disk plaintext.

Risks and rollout

  • No new C build, on any target. Every enabled codec is pure Rust — zune for JPEG/PNG decode, jxl-oxide and zune-jpegxl for JXL, plus tiff and gif — so this adds no cc invocation and no system library anywhere. That is load-bearing rather than incidental: it is why the aarch64 cross-builds work at all, and the reason WebP is absent (see above).
  • Peak memory. MAX_DECODE_PIXELS is 128 Mpx — ~25% headroom over a 102 Mpx medium-format frame. The honest peak at that ceiling is ~2.5 GB, not the ~1 GB a single buffer suggests: zune-png's u16 samples, the realloc that drops alpha, Capsule's RGBA8 copy, and the widening back to RGB u16 for the encode. Since media is implied by native, that peak happens on a phone too, where it is an OOM kill rather than an error — which is why the ceiling is not set as high as an allocation bomb would require.
  • Sidecar content changes for newly imported assets, not the schema: dimensions from decoded pixels, lqip populated, content_type header-derived. Nothing rewrites an existing sidecar, and lqip was already Option and signature-covered per asset, so a revert stops producing placeholders and leaves every existing sidecar valid.
  • New files under derivatives/. A revert orphans them; they are regenerable by design, and the read side already skips a derivative whose bytes no longer content-address its manifest.
  • content_type for a non-still changed. The extension table lost its still rows (header-derived now) and gained the common video suffixes, so a .mov is video/quicktime rather than application/octet-stream. asset_type_for classifies both as before.
  • A small still now produces a manifest with no bytes on disk. lifecycle/upload.rs::derivative_blobs logs derivative manifest has no bytes on disk; skipping and omits the blob, which is correct behaviour for a reference — but it is a per-asset warning that reader was not written to expect. Noted below; upload.rs is outside this lane's manifest.
  • Rollback is per commit; the dependency and the module are additive, and the import wiring is one call site.

Related Issues

Closes #410
Refs #437 (the S-B1 remainder: a lossy JXL master, AVIF encode, the preview tier, HEIC/RAW decode)
Refs #438 (S-B5: video derivatives)
Refs #444 (WebP is blocked upstream: rawshift-image's codec does not compile on aarch64)
Refs #448 (the read half of derivative encryption: capsule_sdk::fetch::open_representation)
Refs #449 (the closed-format check on receipt: capsule_server::upload::envelope::check_envelope)

Decisions taken

Issue 410 - media: capsule-core::media on rawshift-image, the LQIP producer, and typed unsupported formats (S-B1, S-B13, S-B14)
Plan:     r1 (planned against f433d918; executed on the head of lane #399's branch)
Branch:   feat/media-rawshift-still-decode-410
Base:     chore/freeze-capsule-core-api-399 (head of PR #426), stacked; the PR targets that branch
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-media-rawshift-still-decode-410
Cause:    -
Touches:  capsule-core/src/media/** (new: mod, detect, decode, resize, derivative, error — private submodules + one barrel), capsule-core/src/lifecycle/{import.rs (:300-400 prepare_still replaces the constant triple; doc links), derivatives.rs (new), mod.rs (:296-322 doc links, PreparedStill wiring)}, capsule-core/src/import/{executor.rs (:8, :429-520 test expectations), progress.rs (deferred_format_count)}, capsule-core/src/crypto/provenance/manifest.rs (still-role closed-format check; NO field type change), capsule-core/src/lib.rs (media mod line), capsule-core/Cargo.toml (media feature, native ⇒ media, rawshift-image optional dep), capsule-wasm/src/lib.rs (decodeLqip export), capsule-core-ffi/src/catalog.rs (LQIP accessor), Cargo.toml/Cargo.lock, NOTICE (:92-104 MPL list += rawshift-core, rawshift-image), capsule-docs/src/content/docs/design/{dependencies.md (row), thumbnails.md (status note)}, capsule-docs/planned-modules.txt (remove the capsule-core::media line only), capsule-cli/tests/import_round_trip.rs (:18-25, :340-344 decoded path), SLICES.md (rows/blocks S-B1, S-B5, S-B13, S-B14 ONLY), AGENTS.md (the one sentence "nothing in Capsule decodes media right now" → truthful)
Will not: touch capsule-server/**, capsule-web/**, capsule-sdk/**, the rawshift submodule, deny.toml (MPL-2.0 and the jpeg-encoder IJG exception already present), the sidecar schema, DerivativeCore's field types, derivative upload, or any already-imported asset
Lane:     serialised behind #399; parallel with #401/#408/#411. Forecast collision: planned-modules.txt (#411 removes the adjacent capsule-core::notify line).
Settled:  LQIP lives in capsule-core::lqip, unconditional, chromahash 0.7.1 at DEFAULT_TIER, format_version 1 (S-B14). Barrel/pub(crate) convention (#399). Base = head of PR #418 → stacks on #399.

Decisions taken.

1. Deliverable boundary
   Taken:    Decode + orientation + MediaMetadata + the LQIP producer + the closed DerivativeFormat enum + the thumbnail tier (256 px long edge) encoded as WebP q=50, plus the `original` sentinel, the wasm LQIP export and the FFI accessor. Excludes the preview tier, JXL and AVIF encode, HEIC/AVIF/RAW decode and all video.
   Rejected: The full thumbnails.md table now - rawshift-image 0.1.1 has no lossy JXL encoder without C libjxl (jxl-encode-zune is zune-jpegxl's lossless JxlSimpleEncoder; formats/encode.rs:408-412 maps quality 0.0 to 100; jxl-encode-libjxl pulls bindgen + pkg-config); AVIF encode via ravif 0.13 defaults to asm → rav1e/asm and needs nasm on every x86_64 build host incl. CI, cross and cargo-ndk.
   Reverses: Add "avif" (and nasm in CI) or "jxl-encode-libjxl" to the rawshift-image feature list and the matching arms in media::derivative::encode; the enum and tier table already carry both variants.
   Filed:    two follow-ups filed by the lane — the S-B1 remainder (JXL/AVIF encode, preview tier, HEIC/RAW decode) and S-B5 (video derivatives; rawshift-video unpublished/unimplemented). Bodies are drafted in the plan.

2. Which encoder produces the derivative bytes that ship first
   Taken:    WebP lossy via rawshift-image's `webp` feature (libwebp-sys 0.14.4, MIT, vendored static build through cc with pre-generated bindings — the same class of C build rusqlite/bundled already performs), q=50 for the thumbnail tier; image/webp is an admitted value in the thumbnails.md format table. Every encode passes MetadataEmbedOptions::none() (the crate's default embeds EXIF incl. GPS).
   Rejected: JXL-lossless via zune-jpegxl - lossless-only, so a 256 px thumbnail costs several times a q=50 encode. Also rejected: JPEG derivatives - image/jpeg is not in the format table and an unrecognised format is a structural rejection.
   Reverses: Swap `webp` for `jxl` in capsule-core/Cargo.toml and the EncodeOptions arm from LibwebpEncodeConfig to ZuneJxlEncodeConfig.

3. Crate and feature surface
   Taken:    rawshift-image = { version = "0.1.1", default-features = false, features = ["jpeg","png","jxl-decode","tiff-decode","gif-decode","webp"], optional = true }, gated by a new `media` feature implied by `native` and absent from the wasm no-default-features build. Registry dependency, not the submodule.
   Rejected: The rawshift facade with defaults (no per-format control); enabling heic/avif (system libheif / libdav1d break cross and cargo-ndk); the pinned submodule (uninitialised newer v1-in-progress tree, not a workspace member).
   Reverses: One manifest edit; license-check and build-check-wasm re-decide it.

4. How the closed derivative-format set is enforced
   Taken:    DerivativeCore.format stays String with plain serde; a closed DerivativeFormat enum with mime()/parse() and the `original` sentinel is the only producer of still-role values, and verification rejects a still-role manifest whose format does not parse. Signed CBOR bytes unchanged for every existing manifest.
   Rejected: Typing the field as the enum - ml/registry.rs:85 writes embedding/{model_id} into the same field, and an unparseable value would fail at deserialisation before any signature is examined.
   Reverses: Change the field type in manifest.rs:249 and extend the enum with Embedding(String) plus a serde bridge.

5. Which encoder produces the derivative bytes that ship first — REVERSES decision 2 on CI evidence
   Taken:    The thumbnail tier encodes JXL via rawshift-image's pure-Rust `jxl-encode-zune` backend
             (`image/jxl`, the format table's master format), and the `webp` feature is removed from
             capsule-core/Cargo.toml. Taken because WebP does not compile on aarch64 (E0308 at
             rawshift-image webp.rs:164 vs libwebp-sys ffi.rs:881) and every mobile target is
             aarch64; JXL-lossless costs more bytes per thumbnail but is pure Rust, in the format
             table, and mobile-safe.
   Rejected: Keeping webp behind a `cfg(target_arch)` gate (thumbnails would exist on desktop and be
             absent on every phone — the primary platform tier); JPEG (not in the format table).
   Reverses: Restore `"webp"` in the feature list and the LibwebpEncodeConfig arm once
             rawshift-image ships an aarch64-clean webp codec.
   Filed:    #444, citing the three error lines and the upstream crate; linked from this PR and
             from #437.
   Authority: taken by the orchestrator under the run's authority, on the CI evidence from this
             PR's own red checks at 72e59217.

Decisions taken inside the manifest by this lane

6. Capsule owns detection rather than delegating to detect_standard_format
   Taken:    A Capsule-side magic-byte table over the closed StillFormat set (media/detect.rs), with
             the extension consulted in exactly two places a header cannot settle: refinement of a
             TIFF header into a RAW family, and fallback when the sniff yields nothing.
   Rejected: Using rawshift-image's `detect_standard_format` as the primary table. Its
             `heic|heis|hevc|hevx` arm is `#[cfg(feature = "heic-decode")]`, so a build without the
             HEIC codec cannot recognise an Apple HEIC either (its major brand is `heic`) — the
             file would arrive as MediaError::NotAStillImage rather than UnsupportedFormat, which is
             the difference between "no still here" and "a still whose derivatives are
             backfillable". The reference library is HEIC end to end, so that is precisely the case
             S-B13 exists to keep honest. Two smaller reasons ride along, both about the brand table
             rather than a feature: the crate reads the generic HEIF brand `mif1` as AVIF, and does
             not recognise `heix` or `msf1` at all. The two tables are held together by
             `still_format_agrees_with_rawshift_detection` over every format both define
             unconditionally; the ISO-BMFF brands are excluded there and the divergence documented.
   Reverses: Delete StillFormat::from_bytes and map StandardFormat instead; the agreement test is
             the thing that would then have nothing to compare.

7. lifecycle/derivatives.rs is NOT feature-gated
   Taken:    No `#[cfg(feature = "media")]` on the module or on `prepare_still`. `native` implies
             `media` and the whole `lifecycle` module is `native`-gated, so a build that compiles
             this file always has the codec stack — the two builds that drop `media`
             (capsule-server, capsule-wasm, both default-features = false) drop `lifecycle` with it.
   Rejected: The plan's `#[cfg(not(feature = "media"))]` constant-DeferredNoCodec fallback body. It
             would guard nothing while making every signature read as optional, and it would need a
             cfg-gated field on PreparedStill (GeneratedDerivative is a media-only type). If the
             `native ⇒ media` implication is ever removed, this file fails to compile, which is the
             right way for that decision to surface rather than silently producing no placeholders.
   Reverses: Re-add the cfg pair; the constant triple it replaces is one commit back.

8. Netpbm is recognised, not decoded
   Taken:    StillFormat::Ppm is in the closed set and out of SUPPORTED_STILL_FORMATS, so a `.ppm`
             is a typed UnsupportedFormat deferral rather than "not a still image".
   Rejected: Adding `ppm-decode` to the feature list to make it decodable. It is pure Rust and
             cheap, but Netpbm is an intermediate and test-fixture format rather than something a
             photo library holds, and Decision 3 pins the feature list; widening it for a format no
             user imports is not a trade this diff should make. Also rejected: dropping the variant,
             which would have made a Netpbm file report as a non-image.
   Reverses: Add "ppm-decode" to the feature list and StillFormat::Ppm to SUPPORTED_STILL_FORMATS;
             `is_decodable_matches_the_supported_table` is the test that re-decides it.

9. planned-modules.txt narrows rather than loses its media row
   Taken:    The `capsule-core::media` row is replaced by `capsule-core::media::video`, not deleted.
   Rejected: Deleting the row outright, as the lane brief's literal wording says. `check-docs-truth`
             resolves `capsule-core::media::video::derivative`, which design/licensing.md:79 names,
             and that submodule genuinely does not exist — with no covering entry the gate fails on
             a doc file outside this lane's manifest. Narrowing keeps the gate honest, keeps the edit
             inside the one row the manifest names, and states the remaining commitment (S-B5, filed
             as #438) rather than dropping it.
   Reverses: Delete the row and edit design/licensing.md to stop naming the unbuilt submodule.

10. DecodedImage carries crate::lqip::RgbaImage rather than its own buffer type
   Taken:    `DecodedImage { image: RgbaImage, gamut, orientation_applied, format }`, reusing the
             unconditional LQIP buffer type.
   Rejected: The plan's bare width/height/rgba triple. Reusing RgbaImage means the decode output is
             already exactly what `Lqip::encode` and `downscale_rgba8` take, and no `media`-only
             type reaches the LQIP contract — the coupling S-B14 exists to prevent.
   Reverses: Inline the three fields; every call site reads `.image.rgba` today.

11. The closed-set check lives in media; manifest.rs takes only a doc pointer
    Taken:    `media::verify_still_format` is the check; `DerivativeCore.format` gains a doc block
              recording why the field stays `String` and where the enforcement is.
    Rejected: A `DerivativeManifest::structural_ok` in crypto/provenance/manifest.rs.
              `DerivativeFormat` is behind the `media` feature and `crypto::provenance` is
              unconditional (capsule-server and capsule-wasm must read a manifest without linking a
              codec), so the check would need either a feature-gated public method or a duplicated
              copy of the mime table — and a second mapping point is exactly what Decision 4 avoids.
    Reverses: Move the enum into crypto/provenance and add the method there.

12. The `original` sentinel carries no bytes
    Taken:    A sentinel derivative's `GeneratedDerivative::bytes` is empty and `persist_derivatives`
              writes no file for it; its signed manifest still lands in the bundle, and its
              `ciphertext_hash` content-addresses the original.
    Rejected: The retired implementation's behaviour, which this diff first reproduced: copy the
              whole original into `derivatives/{uuid}.thumbnail.{ext}`. That puts the original —
              EXIF and GPS intact — into a *derivative blob*, i.e. the one artefact a re-encoded
              thumbnail is metadata-free by construction, and duplicates a file two directories up.
              The contract's word is "references", and a signed content address is the reference.
              Also rejected: omitting the manifest too, which would erase the distinction between
              "the original *is* the thumbnail" and "the thumbnail is missing, rebuild it".
    Reverses: Drop the `sentinel.bytes.clear()` and the `extension()` guard in persist_derivatives.
    Cost:     `lifecycle/upload.rs::derivative_blobs` logs one "no bytes on disk; skipping" warning
              per small asset. That reader is outside this lane's manifest; see the review notes.

13. A codec failure does not fail the import; it is reported as DecodeFailed
    Taken:    `generate_still_derivatives` returning `Err` is warned and mapped to
              `DerivativeStatus::DecodeFailed` with the real `dimensions` and `lqip` retained, not
              propagated. `DecodeFailed`'s doc is widened to name both causes (bytes that did not
              decode; a frame the encoder refused), because the question a reader has of that bucket
              is "should somebody look at this?" and for both the answer is yes.
    Rejected: Propagating as `LifecycleError::Io`, which the first draft did — it would fail an
              import over a thumbnail, trading a missing derivative for a missing backup, and the
              module's own docs say only workspace-level faults propagate. Also rejected: keeping
              `Decoded` with an empty derivative list, which is accurate about the decode but makes
              an encoder refusing a valid frame invisible in the run summary.
    Reverses: Restore the `map_err(LifecycleError::Io)?`; the status enum is unchanged either way.

14. The unwind boundary covers every step that runs foreign code over pixels
    Taken:    `media::guarded(stage, step)` wraps the decode, the chromahash placeholder and the
              WebP encode. `decode_guarded` is now a thin call to it.
    Rejected: Guarding only `Decoder::decode`, as the plan said. The module claims an import can
              never abort over a thumbnail, and chromahash is also pre-1.0 (its `encode` panics on
              a zero dimension) while libwebp is reached through FFI — so the narrow guard made the
              claim nearly true, which is worse than a narrower claim.
    Reverses: Inline `catch_unwind` back into `decode_guarded` and drop `guarded`.

15. MAX_DECODE_PIXELS is 128 Mpx, and its cost is stated honestly
    Taken:    128 Mpx (~25% headroom over a 102 Mpx medium-format frame), with the constant's doc
              naming the real ~2.5 GB peak: zune-png's u16 samples, the realloc that drops alpha,
              Capsule's RGBA8 copy, and the widening back to RGB u16 for the encode.
    Rejected: The first draft's 256 Mpx with a "~1.5 GB decoder buffer, ~1 GB copy" note. The note
              understated the peak by ~3-4x by counting one buffer at a time, and `media` is implied
              by `native`, so the peak lands on a phone as an OOM kill rather than an error.
    Reverses: One constant; `an_oversized_header_is_refused_before_decoding` holds at either value.

17. One path outside the lane manifest: a re-export line in capsule-core-ffi/src/lib.rs
    Taken:    `pub use catalog::{Catalog, LqipPlaceholder, render_lqip};` (was `pub use
              catalog::Catalog;`) plus the matching bullet in that file's module doc. The manifest
              names `capsule-core-ffi/src/catalog.rs` for the LQIP accessor and not `lib.rs`.
    Why:      `mod catalog` is private, and the workspace clippy flags include `-W unreachable_pub
              -D warnings`. A `pub` item in a private module that the crate root does not re-export
              is a hard error, so the accessor and its record type are unreachable — literally
              uncompilable — without this line. `pub(crate)` is not an escape either: uniffi's
              generated scaffolding puts the type in an exported signature, which then trips
              `private_interfaces`.
    Rejected: Freezing the lane and returning a revised manifest for one re-export, which the
              preamble's letter allows but which would have stopped the whole slice over a line
              that adds no surface the manifest did not already sanction — the accessor itself is
              in the manifest, and this only makes it reachable. Recorded here and in the lane
              report rather than taken silently.
    Reverses: Drop the two names from the `pub use` and delete the accessor with them.

16. MediaMetadata::gamut is documented as always sRGB rather than presented as a mapping
    Taken:    The `gamut_of` mapping stays as the single point that will change, and both its doc
              and `MediaMetadata::gamut`'s say plainly that `probe_standard_image` hard-codes
              `ColorSpace::Srgb` for every format (and `decode_standard_image` tags sRGB without
              converting), so the wide-gamut arms are unreachable in this build.
    Rejected: Leaving the original doc, which claimed the field carried "the source colour space".
              It does not, and a Display P3 source therefore gets a placeholder interpreted as sRGB
              — under-saturated, the direction S-B14 chose when it had to pick one. A fidelity
              limitation stated is worth more than a mapping implied.
    Reverses: Nothing to reverse here; wiring a real gamut needs upstream to report one.

Decisions taken in the repair round (orchestrator authority)

18. Derivative bytes are encrypted before they cross the network
    Taken:    `DerivativeCore` gains a REQUIRED `nonce_prefix` after `ciphertext_hash`, of the type
              `ManifestCore` uses. Generation encrypts each derivative with the same construction
              the original uses -- `encrypt_asset_rekey(&amk, &asset_id, plaintext, None)`, a fresh
              CSPRNG prefix per derivative -- and signs the CIPHERTEXT's address; the ciphertext is
              discarded, the plaintext derivative stays on disk for the local gallery, and
              `derivative_blobs` re-derives the ciphertext at push from the recorded prefix, gates
              it on the signed address, and puts it in `DerivativeBlob.bytes`. The `original`
              sentinel stays byte-free and encrypts nothing: it signs the ORIGINAL's ciphertext
              address and nonce prefix, which is what makes it a reference. This required
              reordering `import_asset_with` so the original is encrypted before derivatives are
              generated. `media` gains a narrow `DerivativeSealer` seam, so the codec module still
              names no key material and `lifecycle` still names no codec.
    Rejected: An `Option<nonce_prefix>` with a fixture carve-out. A receiver that cannot recover the
              prefix cannot open the blob at all, so an absent one is an unopenable derivative, not
              a tolerable gap -- and it is safe to require because no real `derivative-manifest/v1`
              has ever been written to any store (derivatives were unconditionally
              `DeferredNoCodec` until this PR, and `crate::ml` constructs none). The schema string
              therefore stays `derivative-manifest/v1`. Also rejected: encrypting at persist time
              and storing the ciphertext on disk, which would leave the local gallery unable to
              paint a thumbnail without a key round trip, and would diverge from how the original
              is held (plaintext locally, ciphertext re-derived at push).
    Reverses: Drop the field, restore `ciphertext_hash: hash::hash_bytes(bytes)` in
              `sign_derivative`, and hand `plaintext` to `DerivativeBlob.bytes`.
    Filed:    #448 -- the READ half. `capsule_sdk::fetch::open_representation` returns the fetched
              bytes and neither decrypts them nor has the material to; no `decrypt_asset_vec` call
              exists anywhere in `capsule-sdk/src`. Not a regression (nothing fetched a derivative
              before either, because none had ever been produced), and the write side moved first
              deliberately -- shipping plaintext thumbnails to a server that must not see them was
              the worse order to fix it in.

19. `verify_still_format` is wired at the boundary that ships bytes
    Taken:    `derivative_blobs` runs the closed-set check; a still-role manifest naming a format
              outside the committed set is skipped with a warning. Both arms tested, with the bytes
              and the hash held equal so only the format moves.
    Rejected: Leaving it as a tested-but-uncalled function, which is what the previous round did and
              recorded as unresolved note 2.
    Reverses: Delete the `match verify_still_format(...)` block; the tests are the thing that would
              then have nothing to exercise.

20. The byte-free `original` sentinel is an expected reference, not a missing file
    Taken:    `derivative_blobs` recognises the sentinel and skips it at `debug!`. The `warn!` stays
              for a non-sentinel manifest whose bytes are gone, and both are tested.
    Rejected: Keeping the warning for every byte-free manifest. An expected absence logged as a
              problem, once per small asset, is how people learn to ignore warnings.
    Reverses: Drop the `Ok(Some(DerivativeFormat::Original))` arm.

21. The manifest widening for this round is recorded, not silent
    Taken:    `capsule-core/src/lifecycle/upload.rs`, `capsule-core/src/crypto/provenance/manifest.rs`
              and `capsule-sdk/src/push.rs` (docs only) joined the lane manifest for the repair
              round, on the orchestrator's authority. `capsule-server` untouched; `SLICES.md` prose
              outside the four rows untouched.
    Rejected: Filing the encryption hole instead of fixing it, which is what the previous round did.
    Reverses: n/a -- a record, not a code change.

Decisions taken in review round 1 (orchestrator authority)

22. An encoder refusal must never cost the original (F1, Critical)
    Taken:    `MediaError` gains a `Sign` variant, so a workspace fault -- a hardware signer
              refusing, a missing epoch write-tier key -- is distinguishable at the TYPE level from
              a codec refusing pixels. `prepare_still` propagates only the former; every codec,
              resize and encode failure degrades to `DerivativeStatus::DecodeFailed` with the real
              `dimensions` and `lqip` kept, and the import commits.
    Rejected: c -- keeping the behaviour and rewriting the four records that describe it. The
              records were right and the code was wrong: the module header, `S-B13` and decision 13
              all said an unreadable derivative never costs a backup, and the code propagated
              `LifecycleError::Io` from BEFORE `write_asset_files`, so an encoder refusal lost the
              original outright. Also rejected: string-matching the crypto error's message to tell
              the two apart, which is a type problem answered with a substring.
    Reverses: Fold `Sign` back into `Encode` and restore the `?`.

23. Every stage that runs foreign code over pixels is guarded (F2, High)
    Taken:    `guarded(stage, ..)` wraps the decode, the chromahash placeholder and the JXL encode;
              a caught unwind maps to `DecodeFailed` through the existing `DecoderPanic`
              classification, with the stage named so the panic is attributable. `guarded` is
              `pub(crate)`: `lifecycle` is its only caller and no client of this crate has pixels.
    Rejected: Guarding the decode alone, which is what the code did while the module claimed
              otherwise. chromahash and `zune-jpegxl` are both pre-1.0 and both run AFTER the
              decoder on the same untrusted frame, so one panicking photo could abort a
              twenty-thousand-photo import part way through.
    Reverses: Inline `catch_unwind` back into `decode_guarded` and drop the other two call sites.

24. The closed format set moves out of `media` (F9, Medium)
    Taken:    `DerivativeFormat` (mime table, `parse`, `extension`, `is_encodable`) and
              `verify_still_format` move to the unconditional `capsule_core::derivative_format`,
              re-exported from `media` so every existing path resolves. A test in that module runs
              under `--no-default-features`, which is how the receivers build.
    Rejected: Leaving it behind the `media` feature (decision 11's premise). `native` implies
              `media`, and `capsule-server` and `capsule-wasm` both build `default-features =
              false` -- they are exactly the crates that receive a manifest they did not author,
              and they could not link the check at all. A closed set only its producer can evaluate
              is not a closed set. Placement was a choice, not a constraint.
    Reverses: Move the module back under `media`; the `--no-default-features` test is what would
              then fail to compile, which is the point of it.
    Filed:    #449, naming `capsule_server::upload::envelope::check_envelope` -- the function that
              already runs `check_manifest_envelope` -- as the receiver-side call site, with the
              two shape questions (a key-free entry point taking `(role, format)`; a new
              `EnvelopeReject` arm and its `error.*` catalog key) left to whoever implements it.
              `capsule-server` is outside this lane's manifest.

25. The `original` sentinel is a local record, not a wire artefact (F4, High)
    Taken:    Decision 12's rationale is restated: the sentinel keeps "this original is small"
              apart from "this thumbnail is missing" for the CLIENT's own rebuild path. A receiver
              does not need it -- the encrypted sidecar carries the asset's pixel `dimensions`, so
              a receiver can see that an original at or below the tier's long edge needs no
              thumbnail and must not schedule a backfill. `thumbnails.md` says so in one sentence,
              and the reader admits and skips it quietly (decision 20). Under decision 18 its
              `ciphertext_hash` and `nonce_prefix` are the original manifest's, so it names
              something a holder can actually check.
    Rejected: a -- shipping a byte-free manifest on the wire. That is new SDK and server surface
              for a fact the sidecar already carries, and a blob reference with no blob is exactly
              the shape a receiver has no way to act on.
    Reverses: Emit the sentinel manifest into `DerivativeBlob` with empty bytes and teach the
              server to accept a zero-length blob.

26. Derivative chains are per asset-role across time (F14, Low)
    Taken:    `DerivativeContext` carries `prior_heads`, the current head of each role's chain,
              read off the persisted bundle by `chain_heads`. Empty on a create; a regeneration
              extends the chain. A `HashMap` because `DerivativeRole` derives `Hash + Eq` and not
              `Ord`, and adding `Ord` to a signed wire type to key a lookup would be the tail
              wagging the dog -- iteration order never escapes the map, so no non-determinism
              reaches the signed bytes.
    Rejected: a -- restarting each role's chain per invocation. A #437 backfill would then fork the
              record rather than extend it, and a forked chain is not something a later run can
              repair, so it had to be right before the backfill exists rather than after.
    Reverses: Drop the field and reset `prior` to `None` per call.

27. JPEG/PNG encoders leave the shipping graph (F12, Low)
    Taken:    `jpeg-decode`/`png-decode` for the normal dependency; the `jpeg`/`png` bundles (which
              add the encoders) ride `[dev-dependencies]`. Only the test fixtures encode JPEG or
              PNG. `cargo tree -p capsule-core -e normal -i jpeg-encoder` is now empty, so
              `jpeg-encoder`'s conjunctive IJG arm -- which cannot be elected away -- is out of
              every release binary, while `cargo deny --all-features` still sees it through
              dev-dependencies, so its `deny.toml` exception and NOTICE section stay MATCHED rather
              than going stale. `dependencies.md` records the split.
    Rejected: a -- keeping `jpeg`/`png` and recording the licence surface. 0.1.1 does expose
              decode-only features, so the recorded-exception fallback was not needed.
    Reverses: One manifest edit back to `jpeg`/`png`; `license-check` is the gate that re-decides
              it either way.

28. A malformed placeholder never fails a viewer, on either surface (F11, Low)
    Taken:    `capsule-wasm`'s `decodeLqip` is infallible and paints the same documented fallback
              fill `capsule-core-ffi`'s `render_lqip` paints; both suites assert the identical
              answer for the identical malformed record.
    Rejected: Making both throw. A placeholder is cosmetic -- it never damages a library and its
              absence loses nothing -- so failing a gallery over one trades a blurry square for a
              broken screen. The previous split (throw in the browser, black on the FFI) was worse
              than either: one record answered two ways depending on which client opened it, which
              is the client-dependent divergence `capsule-core::lqip` exists to prevent.
    Reverses: Restore the `Result` return and the `err::MALFORMED` throw.

29. `deferred_formats` widens two public types, deliberately and before the freeze (F16, Low)
    Taken:    `lifecycle::SignedImport::deferred_formats` and
              `import::ImportOutcome::Imported::deferred_formats` are additive fields on public
              types, added on the API-freeze branch before the freeze lands. The freeze governs
              post-merge changes through ADRs; this is named here so the widening is a recorded
              decision rather than something a reader discovers in a diff.
    Rejected: Carrying the count off the public types -- a side channel, keyed by asset id, for a
              number that belongs to the outcome it describes.
    Reverses: Drop both fields and `ImportExecutionSummary::deferred_format_count`.

Decisions taken in review round 2 (orchestrator authority)

30. The nonce-prefix reuse refusal is implemented for derivatives (M4, Medium)
    Taken:    The sealer carries the set of prefixes already used for this `file_id` -- the
              original manifest's, plus every derivative manifest's in the existing bundle, which
              the bundle reader already opens -- refuses a drawn prefix in that set and REDRAWS,
              and adds each newly sealed prefix before the next seal. An exhausted draw
              (`MAX_PREFIX_DRAWS = 8`) is `MediaError::Sign`, which decision 22 propagates: a
              1-in-2^56 collision eight times running is a broken CSPRNG, i.e. a workspace fault
              rather than this asset's missing thumbnail.
    Rejected: Recording the gap and amending encryption.md. The sentence is normative and the
              keystream separation of the whole construction rests on it: a prefix is folded into
              the file-key salt, so a reused prefix reuses the KEY, and two blobs under one
              keystream is precisely the failure the design is written to prevent. Also rejected:
              failing outright on the first collision, which turns a recoverable draw into a lost
              derivative.
    Reverses: Restore `encrypt_asset_rekey(.., None)` in the sealer and drop `used_prefixes` from
              the bundle reader.

31. A derivative manifest naming an unheld AMK epoch is skipped, not indexed (M2, Medium)
    Taken:    `derivative_blobs` checks `album.amks.contains_key(&epoch)` before `file_key`, and a
              manifest naming an epoch the album does not hold becomes the fifth skip reason
              (`warn!` + `continue`). The rustdoc enumerates five.
    Rejected: Leaving the index. `file_key` reaches `album.amks[&epoch]` with an epoch read from an
              unverified on-disk `.cbor`, inside a function whose contract is that it never fails
              the bundle -- and it needs no tampering to reach, because an album recovered from a
              backup holds only the epochs it escrowed.
    Reverses: Delete the `contains_key` guard; the test then panics with `no entry found for key`,
              which is what it was mutation-checked against.

32. Embedding-role manifests are out of this reader's scope, and say so (M3, Medium)
    Taken:    `derivative_blobs` skips an embedding-role manifest at the `verify_still_format`
              match with a `debug!` naming the role, and the reader's doc says embeddings are out
              of scope until `crate::ml` produces derivative manifests. Recorded in the unresolved
              notes; no issue filed, because nothing produces them.
    Rejected: An embedding arm with no producer -- speculative handling for an artefact with no
              writer. Also rejected: leaving it as it was, which after F5 keyed the reader by
              (role, format) made every embedding manifest fall through to a "no bytes on disk"
              warning that was simply untrue of it.
    Reverses: Drop the `Ok(None)` arm; the case then rejoins the missing-bytes path.

Unresolved review notes

An adversarial read of this diff produced five notes, and review round 1 produced sixteen findings and six design questions. All of the findings and all but two of the notes are closed by decisions 18-29; what remains is below.

  1. Derivative blobs are uploaded in the clear. Closed by decision 18 — derivatives are encrypted client-side under a fresh per-derivative nonce prefix, and DerivativeBlob::bytes is ciphertext. The read half is filed as media: decrypt fetched derivative blobs — capsule_sdk::fetch::open_representation returns ciphertext with no key path #448, naming capsule_sdk::fetch::open_representation as the function that will own it.
  2. verify_still_format has no production caller. Closed by decision 19 — it runs at derivative_blobs.
  3. The sentinel's byte-free manifest makes derivative_blobs warn per small asset. Closed by decision 20 — recognised as an expected reference and skipped at debug!.
  4. Embedding-role derivative manifests are unhandled by design. derivative_blobs skips them at debug! and names them out of scope. Nothing produces one — crate::ml writes no derivative manifest — so there is no issue to file; when it does, this reader needs an embedding arm and the embedding/{model_id} grammar needs a home.
  5. The closed-format check does not yet run on receipt. Decision 24 made it linkable by the receivers; wiring the call is server: run the closed derivative-format check on receipt — capsule_server::upload::envelope::check_envelope #449, which names capsule_server::upload::envelope::check_envelope and the two shape questions it has to settle. capsule-server is outside this lane's manifest.
  6. WebP is an undecodable format for users, not only an unavailable encoder. The broken upstream module is shared by both directions, so a .webp import lands as a signed original with no placeholder and no thumbnail. WebP is a common export format; media: WebP delivery encode is blocked — rawshift-image 0.1.1's webp codec does not compile on aarch64 (c_char mismatch vs libwebp-sys 0.14.4) #444 is the fix, and it is one upstream cast.
  7. SLICES.md's Lane B intro paragraph is stale: it still says capsule_core::media "does not" survive and that "there is no decoder in the workspace at all". This lane's edits to SLICES.md are restricted to the S-B1/S-B5/S-B13/S-B14 rows and detail blocks, and that paragraph is neither. It belongs to whoever owns the prose.

Two things this PR got wrong and then fixed, worth flagging rather than burying. Both are the same failure class — a whole-region find-and-replace that silently dropped code — and the second is the more instructive.

(1) Commit fe1e3c97's message claims the unwind boundary was widened to cover the chromahash placeholder and the encode, and that a derivative-generation failure is reported rather than propagated. Neither edit actually applied — a silent find-and-replace miss — and no test covered either path, so the suite stayed green over a commit message that was not true of the code. Both are applied in de756e90, which says so. The lesson is in the test gap, not the typo: a claim about a failure path needs a test that enters that path.

(2) de756e90's rewrite of a doc comment at the end of lifecycle/upload.rs overran the file and deleted #[cfg(test)] mod tests;. Thirteen tests stopped being compiled — nine of them the ones proving this PR's central claims, two of which had never been compiled even once — and the suite stayed green, because a lost mod declaration presents as a passing run. Restored in fc7a6d17; all thirteen passed unmodified, so the tests were right and only their declaration was gone. cargo nextest list is the check that sees this, which is why its census is in the Validation section above rather than a summary line.

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 — see ## Validation for the exact commands and classifications.
  • No sensitive info / secrets
  • Docs updated if needed

`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.
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 42d21ee
Status: ✅  Deploy successful!
Preview URL: https://cd5f92e1.capsule-22k.pages.dev
Branch Preview URL: https://feat-media-rawshift-still-de.capsule-22k.pages.dev

View logs

`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.
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.
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.
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.
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