Skip to content

[CHORE] Integration head: merge the programme, then audit the API and recount the trackers - #479

Open
justin13888 wants to merge 299 commits into
chore/merge-v1-head-397from
chore/api-audit-and-roadmap-417
Open

[CHORE] Integration head: merge the programme, then audit the API and recount the trackers#479
justin13888 wants to merge 299 commits into
chore/merge-v1-head-397from
chore/api-audit-and-roadmap-417

Conversation

@justin13888

@justin13888 justin13888 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is the run's integration branch. Every other lane PR targets its stack parent, so no
branch in the programme contains the whole of it — and SLICES.md's row-count paragraph and
ROADMAP.md's per-package State column can only be made true against a tree that holds all of
it. This branch is that tree: each terminal lane tip is merged in by merge commit, in dependency
order, and the bookkeeping those two files owe is computed against the result.

Nothing is merged into master. This PR targets chore/merge-v1-head-397 like every other lane.

All twenty lanes are merged, and the bookkeeping is done against the finished tree: the
SLICES.md recount, the ROADMAP.md states, and the API audit over the four crates the
invocation froze.

The merge table

Each lane merged at the exact SHA below. "Trivial" means the merge produced no conflict.

# Branch PR Merged at Merge
1 docs/reset-trackers-and-add-roadmap-398 #422 09b95705 trivial
2 chore/retire-capsule-wire-400 #430 be5a12ab trivial
3 chore/delete-core-import-media-bucket-423 #431 8486a5c3 trivial
4 chore/close-ci-gate-holes-416 #419 b3c2c754 trivial
5 fix/i18n-plurals-and-swift-detector-414 #420 8c191fb4 trivial
6 chore/freeze-capsule-core-api-399 #426 f508bf1a trivial
7 feat/core-notify-alert-classes-411 #433 eb2a4455 trivial
8 fix/sdk-escrow-route-and-401-retry-408 #434 da86f045 trivial
9 feat/media-rawshift-still-decode-410 #436 a0d5a6e0 resolved (3 files)
10 feat/sidecar-unsigned-migration-412 #455 7805ba5b resolved (1 file)
11 docs/reference-generation-415 #443 00f8dce4 resolved (2 files)
12 feat/cli-help-catalogs-show-repair-413 #450 b219db35 resolved (1 file)
13 feat/server-binary-config-operator-commands-401 #435 4dc14504 resolved + interaction
14 feat/valkey-adapters-403 #454 e05c633e resolved (Cargo.lock)
15 feat/postgres-adapters-402 #447 d2a789d7 resolved + interactions
16 fix/protocol-headers-every-route-404 #453 032b6af2 resolved + interaction
17 feat/oidc-relying-party-407 #459 f1ab7d2a resolved + interactions
18 feat/server-album-membership-405 #458 5fcd4a6c resolved + interaction
19 feat/server-federation-406 #472 b8babdb3 resolved + interaction
20 test/e2e-cases-409 #463 b0fbfa1c resolved + interactions

Merges 1–18 were made at each branch's origin/ head; 19 and 20 at the fixed points the
run recorded. #472 already contained #458 at 5fcd4a6c, so that side merged empty as expected
and everything that did conflict was federation meeting something else.

Merge interactions found

A merge interaction is a failure that exists on neither branch alone. Thirteen were found;
all thirteen are fixed here and none is reported as pre-existing.

Manifest and signature collisions, where two lanes grew the same thing independently:

  1. capsule-server/Cargo.toml duplicate tempfile key ([FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434 × [FEAT] Give capsule-server a binary, configuration, operator commands and a serve task #435) — both added the same
    dev-dependency with its own rationale and the textual merge kept both, so the manifest would
    not load.
  2. capsule-server/Cargo.toml duplicate testcontainers keys (server: Postgres adapters and a conformance suite for every durable port #402 × server: Valkey adapters for the auth-state and upload-session ports #403); features unioned.
  3. Fixture::build grew a parameter on each side (auth: OIDC relying party on the server and SDK/CLI login flows (S-N1, S-N2) #407/core: the share-link privacy strip has no implementation and no slice owns writing one #432 × [FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472) — one signature carrying
    all four, every constructor updated.
  4. openapi::Member gained a field (server: the protocol headers the design puts on every route are on four operations #404 × server: server-side album membership (S-C51), which the blob 403 and album writes wait on #405), leaving a literal incomplete.
  5. CounterKey gained three variants (server: Valkey adapters for the auth-state and upload-session ports #403 × auth: OIDC relying party on the server and SDK/CLI login flows (S-N1, S-N2) #407) and four more (server: Valkey adapters for the auth-state and upload-session ports #403 × [FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472), leaving
    as_str, ceiling and scope non-exhaustive. [FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472 never saw ceiling(), so four ceilings
    had to be supplied — see Decisions taken.
  6. tests/valkey.rs implements a trait that had been split (server: Postgres adapters and a conformance suite for every durable port #402 × server: Valkey adapters for the auth-state and upload-session ports #403).
  7. Modules gained three contexts (server: server-side album membership (S-C51), which the blob 403 and album writes wait on #405/server: federation — capabilities, the pull path, and the sync capability gate (S-E2, S-E5, S-C49) #406/auth: OIDC relying party on the server and SDK/CLI login flows (S-N1, S-N2) #407 × [TEST] Land the bounded E2E cases in a capsule-e2e crate #463) after [TEST] Land the bounded E2E cases in a capsule-e2e crate #463 enumerated every field.
  8. fast_params() became FAST_PARAMS ([TEST] Land the bounded E2E cases in a capsule-e2e crate #463 × a later test using the old spelling).

Contract and generator interactions, where a gate met a surface it had never seen:

  1. The generated SDK client gained a required parameter (server: the protocol headers the design puts on every route are on four operations #404 × [FIX] Reach the escrow route the contract serves, and retry a 401 once on the typed path #434). Regenerating
    capsule-server/openapi.json reproduced the merged document byte-for-byte, so the contract was
    right and the call sites were the gap; two new status variants made two matches non-exhaustive.
  2. doc-check-rust met capsule-i18n::plural (core: freeze the capsule-core public API and remove the dead surface #399's gate × i18n: ICU plural evaluation in the Rust formatter, and the swift-computed-property detector (S-I7, #394) #414's module). Two public doc
    comments linked to private items. The gate and the file existed on no common branch.
  3. The reference generator refuses composition ([DOCS] Generate the reference section from committed CLI and REST artifacts #443 × auth: OIDC relying party on the server and SDK/CLI login flows (S-N1, S-N2) #407). AuthEndpointsResponse.oidc is
    an optional object, and OpenAPI 3.1 spells that anyOf: [{ $ref }, { type: 'null' }] — which
    every Option<T> over a struct emits. Taught the generator that one idiom; a union of two real
    branches is still refused, and so is a genuine union nested inside an optional.
  4. Two /v1/federation/ routes had no reference group ([DOCS] Generate the reference section from committed CLI and REST artifacts #443 × server: federation — capabilities, the pull path, and the sync capability gate (S-E2, S-E5, S-C49) #406), which the generator
    refuses on purpose so an endpoint family cannot silently fail to publish.

The one that needed a decision rather than a fix:

  1. The Durable boot arm, and two contradictory unit tests (server: Postgres adapters and a conformance suite for every durable port #402 × server: Valkey adapters for the auth-state and upload-session ports #403). Each lane wired its
    own half, left the other refusing, and asserted — on the same environment — that its half was
    the first refusal. Resolved on the merits: the arm demands DATABASE_URL, opens the pool,
    checks the schema, proves Valkey answers PING, and still refuses, naming server: Postgres adapters for the remaining durable ports #446, which owns
    five durable ports neither lane landed. Two unit tests became one container case. server: Valkey adapters for the auth-state and upload-session ports #403's
    container case then proved less than it did — see Risks.

The finding this branch existed to produce

Six of the fourteen E2E cases cannot pass on any tree, and only this one could show it.

capsule-core mints a library's account id locally and writes it into every signed manifest as
created_by_user; capsule-server mints its own at registration. #405 refuses an upload whose
created_by_user is not the authenticated caller. Neither branch had the other, so neither could
see the collision.

The check is correct, not a namespace confusion: the design keeps exactly one account
namespace — the server's — and enforces it identically at capsule-core's verify_asset step 6
and capsule-server's directory::project_version. What is missing is the client seam that would
let a library open as a server account, which is already filed as #467 and whose stated fix
shape is exactly this. The six cases are #[ignore]d against #467 rather than deleted or papered
over, and capsule-server/tests/sdk_client.rs reaches the same property only by re-pointing a
seeded in-memory account at the library's id — something no real registration can do.

Two secondary defects fell out of that diagnosis and are fixed here: upload.rs claimed ops.rs
does not make the same comparison (it does — the check was removed there and restored, and the
comment was never updated), and the rawshift gates row still read "stabilizing, unconsumed".

Validation

Every gate run individually, in the foreground, in this worktree, with
CARGO_TARGET_DIR=/var/tmp/capsule-lane-417/target (unset for gen-bindings and check-web).
All pass on the head of this branch.

Gate Outcome
cargo check --workspace --all-targets pass — after every merge and on the final tree
mise run format-check-rust pass
mise run lint-check-rust pass
mise run doc-check-rust pass (after fix 10)
mise run build-rust pass
mise run i18n-check / i18n-guard pass
mise run openapi-check-kynos pass
mise run architecture-check pass
mise run license-check pass
mise run translate-readme-check pass
mise run build-check-wasm pass
mise run build-ffi / lint-check-ffi pass
mise run gen-bindings pass (CARGO_TARGET_DIR unset)
mise run verify-examples pass
mise run cli-surface-check pass — "command tree is up to date"
mise run test-rust pass — 2382 + 865 + 208, 0 failed, 6 skipped (the #467 cases)
Postgres tier pass — 10/10, CAPSULE_TEST_POSTGRES=1, --lib --test-threads=1
Valkey tier pass — 573/573, CAPSULE_TEST_VALKEY=1, --lib --test valkey
mise run check-docs-truth pass — 4 of 4; roadmap resolves all 47 packages
mise run check-docs pass (after fixes 11 and 12)
mise run check-md pass
mise run check-web pass (bun install was run in this worktree first — node_modules was absent)

cli-surface-check is the one to read. Every lane in this run reported it unavailable
because the task does not exist on their bases; it exists here once #443 is merged, and this is
the first tree that could ever have run it. It passes over the whole surface.

Both container tiers were run with --lib included: the adapter-parity suites live in
#[cfg(test)] mod tests inside src/**/{memory,postgres,valkey}.rs, so a --test <binary>
selection misses them entirely. Postgres was run single-threaded against this host's podman socket.

Pre-existing, verified not an interaction: capsule-core/src/library/receipts.rs:96 carries an
unused-import warning under --all-targets. It is present at the base 99dd4bc8 and on both sides
of every merge (checked with git show), and is invisible to lint-check-rust, which omits
--all-targets (issue #474). Not turned on here.

Not run: check-kotlin / check-swift and the Android build. CI's Build Android app fails on
every branch in this run and on their bases; nothing here touches those trees.

SLICES.md — counted, then cross-checked

The published numbers are counted from the merged table. Summing the lanes' recorded deltas is
the cross-check, and the two agree on the total and disagree on everything else:

Before Counted now
Area 87 ACTIVE / 75 RETIRED / 43 MIXED 90 / 71 / 44
Status 95 done, 60 done*, 28 ready, 9 part, 9 blocked, 4 post-v1 111 done, 62 done*, 16 ready, 9 part, 3 blocked, 4 post-v1

Both breakdowns reconcile to 205. That is the mismatch worth reporting: a total that still adds
up is not evidence that a composition does
, and summing deltas would have reproduced the old
figures exactly. The three remaining blocked rows are S-C47, S-C49 and S-P4; six left the
list in one programme and the prose now names which and why.

API audit — the four crates the invocation froze

Crate Verdict
capsule-core-ffi frozen. Flat surface, no public modules; every public item is uniffi-annotated, so no dead surface and no duplicated path
capsule-wasm frozen. Flat, every public item #[wasm_bindgen], no duplication. decodeLqip/WasmLqipImage are the one export capsule-web does not yet import — Rust half done, viewer half not
capsule-i18n frozen. One public module (plural); no duplication. format_message/format_message_in have no caller outside the crate — every consumer goes through Bundle::format
capsule-core not frozen, and the row says so. #399 converted most modules to pub(crate) mod + pub use, but ten sites still pair pub mod with a re-export of the same items, so ~110 types resolve at two paths. Both paths are live: capsule-wasm imports the long form, capsule-cli the short one

Filed as #480 with the ten sites enumerated.

A correction to the brief. The invocation named cbor, crypto, drop, sharing,
validation, lqip and client_build as cargo features whose no-default-features surface had
to stay intact for three consumers. They are not features — no such features have ever existed,
and cargo check -p capsule-core --no-default-features --features cbor,… fails with "does not
contain these features". They are the ungated pub mod declarations in capsule-core/src/lib.rs,
and what keeps them in the sealing build is the absence of a #[cfg(feature = …)]. The surface is
intact — plain --no-default-features compiles clean — and the real set is one larger:
derivative_format is also ungated, deliberately, because the crates that receive a
DerivativeManifest build with default-features = false. The three consumers are capsule-wasm,
capsule-server, and the direct wasm32-unknown-unknown build in mise run build-check-wasm.
ROADMAP.md records it as modules.

ROADMAP.md

check-docs-truth's roadmap check named two packages with no row — capsule-e2e and
capsule-server/migration, both new in this run — and both now have one. Eight State cells moved
to what this tree proves, each with the gate that proves it. capsule-server moves
rebuildingstabilizing, and its Notes say plainly that serve --memory runs while a
durable serve refuses naming #446, so the row cannot be read as claiming a deployment this tree
cannot produce.

Risks and rollout

The risk is concentrated in the resolutions that were not mechanical: the Durable boot arm,
capsule-sdk/src/push.rs, the escrow error mapping, the CounterKey ceilings, and the reference
generator's nullable unwrap. Each is called out above, in its merge commit, and in Decisions
taken
; each is covered by suites that now pass together.

What this tree proves less of than #463's branch did. Six E2E cases are ignored (#467), so the
real SDK push path is proven against the in-process router and not against a registered account.
And #403's tests/valkey.rs boot case proved the durable arm reached Valkey; under the merged
ordering it can only prove the arm refuses rather than falling back, because proving the former now
needs both services up at once and capsule_server::postgres's container helper is pub(crate).
Both reductions are recorded in the code, not only here.

Two things deliberately not done, because they belong to another lane:

Nothing is merged to master; the user still merges #418 and the stack.

Related Issues

Refs #417. Filed by this lane: #480 (finish the public-path freeze). Named by resolutions here
and unchanged: #437, #438, #444, #446, #467, #474, #475. None is closed by this PR.

Decisions taken

Unresolved review notes

(none yet)

…map-398' into chore/delete-core-import-media-bucket-423
…ire-capsule-wire-400

Resolution: `ROADMAP.md` takes the base's block — its rewordings of the
`capsule-server` and `capsule-wasm` rows, which belong to that lane — with the
single `capsule-wire` row dropped, since this branch retires that package.
`notifications.md` closes the alert class list at five classes and places
them, with their trigger predicates, in `capsule-core::notify` so every
platform evaluates one shared decision function instead of reimplementing
the taxonomy. Nothing implemented it: the module did not exist.

`evaluate(&NotifyInput, now) -> Vec<Alert>` reports the classes true at an
instant; `next_deadline(&NotifyInput, now) -> Option<Timestamp>` returns the
one instant an OS timer must be armed for. Both are pure — `now` is an
argument, nothing is read from a clock, a socket, or SQLite — so the whole
surface is table-driven under a mocked clock, the same discipline as the
recovery cadence whose projection it consumes.

Every predicate input is caller-supplied because this crate holds none of
the trigger state: there is no persisted `last_completed_sync`, no
client-side quota type (quota is server-held and only as current as the last
`GET /v1/quota`), and no quarantine table — a refused sync entry is a
per-entry verdict. `NotifyInput` therefore carries counts and instants only:
no album id, no title, no asset id, nothing a server could author.

`next_deadline` is deliberately narrower than `evaluate`. An armed
notification fires from the OS timer with the app not running, so it cannot
be re-checked on arrival; a deadline is returned only when the alert is
certain to be true when it gets there. That withholds one from the three
server-state classes (no device-computable deadline), from a suppressed or
already-passed one, and from the two that would arrive as a badge rather
than a notification.

Suppression is an input field rather than a state machine this crate owns:
the bounded-snooze-then-badge mechanic already has one owner in the recovery
cadence, and a second copy here would be two owners of one mechanic before
the client half exists to say which shape it needs.

`native`-gated: an alert is composed from decrypted device state, and the
un-gated surface is the key-free guest sealing path, which holds none of it.
`BTreeMap` params plus a fixed emission order make two runs on equal input
byte-equal through serde.
… 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-server` has never been assembled outside its own test fixture, so
"every port has an adapter" and "the router builds from real ones" were
claims rather than assertions.

`config` reads the operator's settings once — command line over environment
over default — and reports **every** fault in one message, because an
operator otherwise restarts the process once per variable. What a subcommand
requires is a parameter: `gc`/`purge`/`scrub` demand a blob root and
deliberately no key material, so a maintenance host never needs the
production token-signing key. `--config PATH` is accepted and refused, which
keeps a configuration-file crate out of a domain the dependencies doc has no
row for while leaving the precedence slot named.

`boot::assemble` is the one composition root and the only place adapters are
chosen. Selection is a two-arm match on `Backends`: `--memory` takes every
deterministic in-crate adapter over a real filesystem blob store, and
anything else refuses. That makes two sentences `store/mod.rs` has carried
since `S-C29` true for the first time — Valkey is required, and the
in-memory adapters are not a deployment profile — because until now there
was no boot path to enforce either.

The account ports and the second factor had no adapter at all, which would
have left `register` and `login` answering their declared refusal on a
development server. `auth::credential` is the Argon2id helper the Postgres
adapter (#402) reuses; `auth::accounts_memory` is a real directory over it —
PHC strings, the timing-equalized miss, a lockout that a password change
clears — and `auth::totp` gains the deterministic store its port's three
properties are all expressible over. None is the permissive credential double
`tests/support/mod.rs` warns must never be linkable by a server.

Refs #401, #402, #403
…binary

`gen_openapi` was this crate's only executable, and the Salvo tree it
replaces shipped four — `capsule-gc`, `capsule-scrub`, `capsule-keygen` and
its own document dump. Four executables would each carry their own copy of
the configuration loader and the composition root, which is the duplication
`boot` exists to prevent, so this is one `capsule-server` binary with
subcommands, as `capsule-cli` already is.

`main.rs` installs error reporting and dispatches; the parsing, the log
stream and every subcommand body live in `cli`, in the library, so a test
asserts against the same code the binary runs.

Logs go to stderr. `gen-openapi` writes a path to stdout and the operator
commands will write a report there, and a subscriber sharing that stream is
how a pipeline ends up parsing a log line — the failure
`capsule-cli/tests/cull_round_trip.rs` works around with `RUST_LOG=off`.

`mise run openapi-kynos` and `openapi-check-kynos` re-point at the
subcommand. The committed `openapi.json` is untouched and
`openapi-check-kynos` passes against it, which is the strongest available
evidence the port preserved the document byte for byte.

BREAKING CHANGE: `cargo run -p capsule-server --bin gen_openapi` is now
`cargo run -p capsule-server -- gen-openapi`.

Refs #401
`capsule_core::notify` decides the alert classes; the apps had no way to
call it. This carries the surface across the boundary and wires the SDK's
own recovery scheduler into it.

`evaluate_alerts(input, now)` and `next_alert_deadline(input, now)` are free
`#[uniffi::export]` functions rather than `FfiWorkspace` methods: the
workspace holds none of the predicate's inputs — no persisted last-sync
instant, no client-side quota type, no quarantine table — so a method would
take the same `FfiNotifyInput` and then lock a mutex it never reads.

`FfiNotifyInput` is flat. uniffi records nest, but a foreign caller
assembling five optional sub-records to ask one question is worse than a
struct whose fields are each independently absent, and presence is explicit:
`last_completed_sync` present means the sync facts are known, and so on.
Timestamps cross as RFC 3339 strings per the existing `changed_at`
precedent, since Kotlin and Swift each have their own instant type.

Nothing is parsed leniently. A malformed instant, or a `suppressed_until`
key that is not one of the six class names, is `FfiError::InvalidArgument`
and never a default — a mistyped instant that silently became "never" would
suppress an alert forever, which is the failure this surface exists to
prevent. `AlertClass::from_wire` gives the boundary one table to parse
against instead of its own copy.

`RecoveryCadence::notify_facts(now)` projects the scheduler into
`RecoveryFacts`. It is derived from `state(now)` rather than from the fields,
so the alert and the prompt the UX renders can never disagree about whether
a check is due; `Badge` reports the spent snooze budget (reported, not
pre-armed) and `RewrapDue` is due now whatever the ladder says, because
repeated failure is not a scheduled check and the closed class set has only
`recovery_check_due` to carry it. The projection lives here because
capsule-sdk depends on capsule-core and never the reverse.
`planned-modules.txt` is the only sanctioned way a design doc may name a
module that is not there, and `check-docs-truth` fails on an entry whose
module has since been built — so the `capsule-core::notify` row has to go in
the same change that builds it, and `notifications.md` has to stop calling
the module planned.

The `S-D29` row keeps `ready` rather than taking `done*`. The predicate is
proven and nothing on a device evaluates it yet: every input is
caller-supplied because the core holds none of the trigger state, so the
remainder is the client half and it is the larger half. The detail block
says which parts are owed and why the `notification.*` keys cannot land
before them — the i18n guard needs a live consumer, and the consumer is the
client half by construction.

Only the S-D29 row and its detail block change; the row-count paragraph, the
gates table and the prose head are untouched.
`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.
`capsule-server serve` binds, says where it landed, and drains on a
termination signal.

The bound address is logged at INFO and also written to stdout as one
`listening on <url>` line. That is not a duplicate: `--listen 127.0.0.1:0`
is a request for the operating system to choose a port, and a caller that
asked for that has no other way to learn which one it got — making them
parse a log format `LOG_FORMAT` can change under them would be a contract
nobody wrote down.

`Shutdown::signals()` covers SIGINT and SIGTERM with a second one forcing,
and the drain deadline defaults to Kynos's own 25 seconds, under the usual
30-second orchestrator window. TLS stays off: `failure-modes.md` is explicit
that application servers do not terminate it, so Kynos's `tls` feature is not
enabled and a certificate cannot be configured by accident.

`tests/binary.rs` drives the process, which is the only way to assert what a
binary does. It proves the four properties an in-process client cannot: the
server binds and reports its port; `capsule-sdk`'s **generated** client
reaches it over TCP and reads a `server-info` record whose published signing
key is the one derived from the configured private key; an account registers
and signs in while a wrong password is refused; and SIGTERM drains to exit 0.
The three refusal cases assert the non-zero code and the message — no
`VALKEY_URL` without `--memory` names the variable, `VALKEY_URL` set names

Refs #401
…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.
`filesystem/maintenance.md` has described these as operator-invoked
commands, schedulable as jobs, since before there was a binary to invoke
them from. They existed only as library functions with no entry point.

Dry run is the default for the two that write, because the first thing an
operator does with a collector is find out what it thinks; `--apply` opts
in and the report says which posture produced it. `scrub` exits 1 on a
non-empty report and mutates nothing, which is what makes it usable as a
monitoring probe — and a truncated deep pass is reported as truncated,
because a clean report from a pass that stopped early is the one answer a
scrub must never give.

Reports name what they found rather than counting it. `CollectionReport`'s
own docs are why: "a count tells an operator that something happened without
telling them what to look at." A scrub's findings print through their own
`Debug`, so a variant added later renders as itself instead of as nothing.

`boot` splits into `assemble` and `assemble_maintenance`. That is not
tidiness: `config` claims `gc`/`purge`/`scrub` need no key material, and the
way to make that true is for the assembly they use to have none in scope,
not for it to build a token signer it then ignores. A maintenance host that
had to hold the production signing key to sweep a directory would be a
reason to put the key on a maintenance host. Both entry points build the
same `Stores`, so the application and the workers never disagree about what
is in the index.

`tests/binary.rs` covers all three against a seeded blob root: the dry run
names the unreferenced blob and leaves it, `--apply` marks it (the sweep is
a later pass, and this profile's mark store does not outlive the process),
the scrub exits 1 with the store byte-identical afterwards, `--deep` finds
the byte mismatch a structural pass cannot see, and none of them is given a
signing key.

Refs #401
`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
… target

`mise run serve-api` and the compose stack behind it went with the Salvo
tree in `S-C59`, so there has been no way to bring a server's services up and
nothing to point a client at.

`capsule-server/compose.yaml` is Postgres 18 and Valkey 9.0.4 — the versions
dependabot is already tracking — with the retired deployment's Valkey flags
carried over verbatim, because those are the flags the session and
upload-session stores were sized against. Both services carry a healthcheck:
`serve-deps` returns as soon as compose has started the containers, so a
developer who runs `serve` immediately afterwards would otherwise race the
database's own startup. There is still no object store: the filesystem
`BLOB_ROOT` is the blob backend.

`serve-deps` and `serve` are separate tasks, because a task that silently
starts containers is a task that leaks them. `serve` supplies no environment
on purpose — it refuses and names what is missing. `serve-memory` is the one
that just works, and its fallback signing key is the published example: every
token it mints is forgeable by anyone who has read this repository, which is
exactly why that task is not `serve`.

`.env.example` documents every setting, its default, and why the default is
what it is. It ships in the release archive, because a release without it is
a binary that refuses to start and an operator reading GitHub to find out
which variables it wanted.

`release.yml` builds `capsule-server` beside `capsule` and puts both in the
one per-target archive: an operator wants the server and the CLI that talks
to it at the same version, and two downloads is two chances to mix versions.
Unix only — Windows is already best-effort for the CLI, and adding a server
build to a job allowed to fail would make "did the Windows CLI ship" harder
to answer.

`dependabot.yml`'s three `/capsule-api` entries were watching a directory
that has not existed since `S-C59`. The cargo one moves to the workspace
root, the docker-compose one to `/capsule-server`, and the `docker` one goes:
no Containerfile exists anywhere in the active tree, and an ecosystem pointed
at an absent file is a permanent dashboard error rather than an update.

Refs #401, #402, #403
The documentation build installs bun and nothing else, so it cannot ask cargo
what `capsule --help` says. Give it a committed artifact to read instead, and a
drift gate that makes a stale one fail CI (slice `S-Z8`).

`capsule_cli::cli::command_tree()` walks the clap tree built from compile-time
attributes and returns JSON. It is the crate's only new public surface: `Cli`
stays `pub(crate)` because the parsed command is dispatch state, not API.

The output is deterministic and independent of the process locale, both because
the artifact is byte-compared by its own gate. Subcommands are sorted by name so
reordering an enum variant cannot churn the file; arguments keep declaration
order, which for a positional is its position. `Command::build` is not called,
so clap's synthesized `--help` is not described sixteen times over, and a
boolean flag is not documented as taking `true` or `false`.

`gen_cli_surface` mirrors `gen_openapi` argument for argument — `[FILE]`
default, `--check`, byte comparison, trailing newline — so the two description
artifacts are one thing to remember rather than two. It adds no dependency:
serde_json and clap were already here.

`cli-surface-check` joins `check-rust` beside `openapi-check-kynos`.
…n one

`local-development.md` said that as a known gap, and it was true. It now
documents the binary, both profiles, the operator commands, where logs go and
where TLS is terminated.

It is deliberately explicit about what the development profile is *not*: the
blob store is real and everything else lives in the process, so a restart
leaves every blob an orphan the scrub will report, and `gc` can only ever
mark because the collector's two-pass design needs a mark store that outlives
the process. Both are consequences a developer would otherwise meet as a
surprise.

It is also explicit that `mise run serve` does not work yet and refuses
rather than pretending, and that `serve-memory`'s fallback signing key is
published — every token it mints is forgeable by anyone who has read this
repository.

`capsule-server/README.md`'s "no binary, no configuration loading" section
becomes "Running it", and the flat "every adapter is in-memory" claim gains
the qualifier it now needs: two of them live beside their ports rather than
in `tests/support/`, and the distinction the port docs were drawing is
between a double and an implementation.

Three stale `serve-api` citations follow: `capsule-web/README.md` asserted
the Kynos server "has no binary yet", and the CLI's default-endpoint comment
and the Swift project's local-networking comment both named a task that
retired in `S-C59`. `SLICES.md`'s three remain, and are not this change's.

Refs #401
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.
…d tree

`/reference/` held one page saying nothing was published there yet. It now
publishes the command line, generated from `capsule-cli/cli-surface.json` by a
bun prebuild step (slice `S-Z8`).

`scripts/gen-reference.mjs` runs on `node:` builtins alone and adds no
dependency, which is what lets it run in the docs job as it stands — bun and
nothing else, no cargo. It writes ordinary content-collection entries, so
Pagefind indexes them, the link validator checks their anchors, the `PageTitle`
override renders their badge, and the notranslate pass marks up their terms. An
embedded renderer that mounted its own application would have forfeited all
four.

The pages are gitignored: a committed copy of generated output is a second
source of truth that can disagree with the artifact it came from, and rule 2 of
`design/developer-docs.md` exists so it cannot. Hand-written prose stays in one
overview per surface, `reference/cli.md` beside the generated directory.

`scripts/reference-groups.mjs` is the ordered page table. `astro.config.mjs`
builds the `Reference` sidebar from it and the generator decides which pages
exist from it, so the sidebar stays hand-curated as `developer-docs.md`
requires while a page with no navigation entry stops being expressible.

A missing, unparseable, or unknown-schema artifact fails the build naming the
path. It never emits a stub: an empty reference page is the confidently-wrong
case the design doc puts above a missing one.

Headings inside artifact prose are demoted rather than interpolated — an
operation description opening at `#` would otherwise inject a second h1 into a
page whose h1 is the Starlight title.

The `docs` path filter now names every artifact the build reads, not just the
site, so a change to a described surface cannot publish a stale page. The
docs-truth walk skips the generated directories for the mirror-image reason
`rawshift/` is skipped: they exist on a machine that has built the site and on
no CI runner, and a check that read them would answer differently in the two
places.
An adversarial read of the first two commits found four ways the pre-arm
model lost an alert it had promised to deliver. All four share a root: an
armed notification fires from the OS timer with the app not running, so
anything the arm decision gets wrong is invisible until an alert simply
fails to arrive.

**One timer per class, not one globally.** `next_deadline` returned the
minimum over both pre-armable classes, so a staleness deadline two weeks out
and a recovery check ninety days out yielded one instant — and a client that
armed it lost the recovery alert entirely on a device the app never ran on
again. `pre_arm_deadlines` now returns the instant per class, which is also
what a client needs to pick the catalog key for the notification it is
arming. `next_deadline` remains as its minimum, documented as the
single-timer convenience it is.

**A snooze defers the timer; it no longer cancels it.** A class snoozed
after it fired was dropped from the arm decision entirely, so the alert
never returned unless the user opened the app — which for `sync_stale` is
precisely the case the pre-arm rule exists for. The snooze end is itself a
deadline the device can compute, so it is armed.

**Disable is its own field.** Snooze and disable are different mechanics
with opposite effects on the timer, so `NotifyInput.disabled` is a separate
set rather than a far-future instant in the snooze map. A sentinel instant
does not survive a string-typed FFI boundary: a client writing "the year
2999" would mean disabled and get a timer armed 975 years out.

**A recovery snooze that ends before the due date no longer pulls the timer
earlier**, which would have fired into no alert; the armed instant is the
later of the two, and the alert reports that same instant rather than
`next_due`.

Also: `RecoveryFacts.rewrap_due` carries the guided-re-wrap escalation, so
the alert for "you told us you lost your recovery secret" is no longer
byte-identical to the routine ninety-day check — the class set is closed, so
a parameter is the only way to distinguish them. The FFI stops parsing
`recovery_snoozed_until` only when `recovery_next_due` happens to be
present, since a validation that runs on one code path is the one that lets
a typo through. And `quarantine_pending` is documented as excluding pending
drops, which have their own class and were otherwise counted twice.
… keys once

Locale independence is the property the drift gate rests on — the artifact is
byte-compared, so a string negotiated from the environment would make
`cli-surface-check` pass or fail according to the developer's `LANG` — and
nothing asserted it. Render the tree under `en_US`, `tr_TR`, and `ja_JP` and
compare; `tr-TR` because it is the locale that breaks case folding, and
`LC_ALL` because that is what `cli_bundle` reads first.

Cover both branches of the `long_about`/`long_help` dedup, which decides whether
the artifact carries the same paragraph twice, over a synthetic `Command` — the
real surface has no argument with a distinct long help, so only a fixture
reaches the branch that keeps one.

The document's field names move into one `field` block. The shape is a
projection of clap's builder API and no type here has it, so it stays
hand-built; naming the keys once is what keeps that from meaning spelled ad hoc,
since a typo is a field `gen-reference.mjs` silently never finds.

Record `ArgAction::Count` as unreachable on today's surface, and why it is
matched anyway.
…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.
`cargo doc --no-deps` documents public items only. The commit that made
59 submodules of `library`, `import`, `db`, `crypto::keys`, `sidecar` and
`domain` crate-private therefore walked them out of the gate's reach: the
gate this series added went blind to exactly the modules the same series
touched, and 11 pre-existing broken links inside them were hidden rather
than fixed.

`doc-check-rust` now passes `--document-private-items`, so every item in
the four frozen crates is linted. That surfaces 23 errors, all repaired
here — 22 in `capsule-core`, 1 in `capsule-i18n`; `capsule-core-ffi` and
`capsule-wasm` were already clean.

Fourteen unresolved links, each for a stated reason:

- `HardwareSigner` is implemented in `tbs`'s `#[cfg(windows)] backend`
  submodule and never imported at module scope, so the bare name resolved
  nowhere — four links now go through `super::HardwareSigner`.
- `keys::tpm` is `#[cfg(feature = "tpm")]` and has no doc page in a
  default build; three links to it become prose that says so.
- `p256::parse_p256_public` is private to a sibling module and so is not
  nameable from `tbs` at all — code span.
- `Tbsi_Is_Tpm_Present` is a `windows-sys` extern behind `cfg(windows)`;
  it gets a Win32 URL reference, matching `Tbsip_Submit_Command` in the
  same header.
- `ingest_current_epoch` is a method, not a free item in its module;
  `ProtocolMessage`, `encrypt_asset_rekey` and `ReferenceAuthority` were
  not in their item's scope. All four now carry a resolvable path.

Seven redundant explicit link targets drop to the shortcut form, and two
ambiguous links (`crypto::verify_asset`, `crate::negotiate` — each both a
module and a function) are disambiguated with `fn@`; both meant the
function.

No `#[allow]` was added: every remaining link either resolves or was
demoted to a code span that states why it cannot.

Verified negatively: a broken link introduced in `import/streaming.rs` —
a now-crate-private module — fails the new gate and **passes** the old
public-only one.

Two further review findings:

- `library::receipts`' module doc still said `BlobRole`/`role_str` were
  re-exported there after that re-export was removed. It now says where
  they are actually reached: `crypto::receipts::BlobRole`, and
  `library::BlobRole` through the storage-verify barrel.
- `keystore`'s `DeviceDek` doc said the two byte formats are
  length-disjoint but linked one type. It now names both, `DekKeypair`
  and `P256HybridDek`, in shortcut form.

Finally, `capsule-wasm`'s duplicate-variant guard used `Vec::dedup`,
which collapses only *consecutive* duplicates — `[A, B, A]` kept its
length and passed. It uses a `HashSet` of discriminants now; confirmed by
introducing a non-adjacent repeat and watching the test fail.
Resolves SLICES.md (7 hunks), capsule-docs/planned-modules.txt and
capsule-sdk/src/push.rs. The SLICES hunks are #422's "blocked, Rawshift is
unconsumed" corrections meeting #436's re-land of that same Rawshift pipeline;
planned-modules.txt takes the union of both sides' removals. push.rs keeps
one point with an empty base.
…eport intake

Review findings M1 and M2, and what M2's second half turned out to be.

**M1.** `reported_user` was a string the peer chose: never required to name an
account here, never canonicalized, never length-capped, and then used as a
counter key — so a pinned peer cycling `"a"`, `"b"`, `"A"`, `"a "` minted itself
a fresh per-account budget each time. Now every field is length-capped before
any store is read (`report_bounds`, generous against a DNS name, a UUID and a
SHA-256 digest), `reported_user` must resolve to an account this server hosts —
otherwise these operators cannot act on it and the row would sit in the queue
forever — and `CounterKey::PeerReports` is a per-peer ceiling that ignores the
account, which is the only key that bounds a peer's total volume.

The `404` is reachable only after the signature verified, so it discloses
account existence to a peer whose key an operator pinned and to nobody else.
`InMemoryModeration` was an unbounded map with no eviction — process memory
that never returns for a `--memory` deployment — and now caps at ten thousand
reports, evicting oldest-first with a `warn`, because a dropped report is a
moderation input nobody will ever see.

**M2, limiter half — landed.** `CounterKey::FederatedIntake` is charged in the
handler *before* the peer-store read and the Ed25519 verification, which is
where the ordering requirement actually was. Its key is the claimed origin and
therefore attacker-chosen; that is stated in the key's own docs rather than
papered over, because this server has no trusted client address to key on
instead — the same missing fact `RegistrationSource` waits on.

**M2, body-cap half — cannot be expressed, filed as #478.** Mounting a second
`BodySize` on a federation group is refused by const evaluation: "two
interceptors covering this route answer with the same status; a consumer could
not tell which one replied". Both produce `413`. Moving `BodySize` off the
router would take `413` off the ten gate-exempt operations, which
`tests/conformance.rs` pins as declared on every operation — `S-C33`'s contract,
not this lane's. `MAX_FEDERATION_BODY_BYTES` is declared, documented as **not
enforced**, and carries the error verbatim so the next person finds the
constraint instead of rediscovering it.
Resolves design/import/pipeline.md as the union of two disjoint edits to one
paragraph: #431 rewrote the closing clause (it deleted
legacy-review/core-import-media/), #455 rewrote the middle (the unsigned read
model is gone, S-D24). Both hold on this tree.
Resolves SLICES.md (3 hunks) and design/developer-docs.md. #443 owns S-Z8/S-Z9/
S-Z10 and landed them, so its rows and detail blocks supersede the earlier
"blocked/ready" corrections they meet. Keeps HEAD's openapi-check-kynos task
name in S-Z9's Tier line: openapi-check no longer exists in mise.toml. The
blocked-row prose paragraph is resolved provisionally and gets recounted against
the complete tree.
Resolves SLICES.md as a per-row union: #420 owns S-I7, #450 owns S-I8, each
side left the other's row at base.
…reached yet

Review finding M3. `SLICES.md` was honest about this and the two design docs
were not: `moderation.md` said both federated halves "ship" and described the
blocklist as consulted at four boundaries, and `federation.md`'s status note
said they were in. Both are built and neither is reachable, for one shared
reason — peer keys are operator-pinned and nothing can pin one, because
`boot::assemble` refuses the durable backend until #403 and a command running
against `--memory` would forget what it pinned.

Corrected where a reader actually hits the claim: the blocklist bullet itself,
not only the status note, and the same fact in `federation.md`. Both name #403
for the cause and #476 for the command that clears it.

That document's own rule is that a blocklist nothing consults reads as
protection. One nothing can write reads the same, so the route's own module
docs now open by saying its only reachable answer today is `403
error.federation.peer_unknown`. It stays mounted: a peer implementing against
the published contract needs the operation to exist and answer honestly, and
what is missing is the command, not the surface.
…-401 (#435)

Merge interaction fixed: #434 and #435 each added a tempfile dev-dependency to
capsule-server/Cargo.toml with its own rationale, and the textual merge kept both
keys, so the manifest failed to load. Folded into one entry carrying both.

Accepts #435's deletion of the gen_openapi [[bin]] — the dump is now the
gen-openapi subcommand of the one server binary and mise's openapi-kynos task
already calls it; HEAD's edit to the deleted file was module documentation.
capsule-cli/src/remote.rs takes #435's wording: it landed the binary and the
serve-memory task whose absence HEAD's comment described.
Cargo.lock is generated, so it is resolved by regeneration rather than by hand:
cargo restored the media dependency graph #436 added on top of #454's lock.
…t a scope hides

Review findings L5 and Q6.

**L5.** `BlobRole::Backup` answered `403 error.federation.scope_insufficient`.
That `403`'s whole justification is that the feed already told the peer the
asset is there, so a `404` would send it hunting an address that exists — true
of an original under a derivative-only grant, and false of a backup, which the
same module calls the owner's durability artefact rather than part of what was
shared and which the feed never names. A peer holds no fact about it, so it now
gets what a stranger gets.

**Q6, decided: the feed is not filtered by scope, and `federation.md` says
why.** A derivative-only peer receives every blob's role, hash and size and is
refused at the fetch. Filtering the entry's `blobs` array would be theatre:
every entry also carries the asset's signed manifest as the exact bytes the
client uploaded, its `ciphertext_hash` *is* the original's content address, the
peer needs that manifest to verify anything, and the server cannot rewrite it —
a re-serialized manifest is detached from its signatures. The address would
still be present, unfilterable, two fields away, and the feed would no longer
agree with the manifest beside it.

So the doc states the boundary plainly instead: a scope decides which bytes are
served, never which identifiers are learned; a derivative-only grant does not
promise that the peer cannot learn an original exists, its address or its size;
and anyone needing that wants a separate album, because the confidentiality
boundary is the MLS album key and always was.
…he codes out

Review findings L2, L3 and L4.

**L2 — and it caught a real one.** The federation conformance suite fixed
`granted_epoch` at 3 everywhere, which is precisely the divergence class it
exists to catch and could not: the port says `u64`, the durable column is a
`BIGINT`, and an epoch above `i64::MAX` is representable to a caller but not to
one adapter — the shape #458 shipped. The new case asserts the widest accepted
value round-trips unchanged, that zero is a legitimate epoch, and that one past
the boundary is **refused rather than narrowed**, because a grant recorded under
a different epoch than the one asked for admits the wrong membership.

It failed on first run against the in-memory adapter, which accepted what
Postgres would have refused. The check now lives in `store::admissible`, shared
by every adapter, so the two cannot draw the line in different places.

**L3.** `capsule-sdk/src/federation.rs` set `code: String::new()` on the blob
and revocation-list refusals while its own docs promised
`error.federation.scope_insufficient`, so a peer had to string-match prose.
`FetchError::AuthorizationChanged` now carries the code — the `403`s on that
route stopped saying one thing when the capability arm landed, and an account's
`error.blob.access_revoked` ("re-sync membership") and a peer's
`error.federation.scope_insufficient` ("the grant never covered this") call for
opposite actions.

**L4.** `unwrap_or(u64::MAX)` on `max_staleness_seconds` became `unwrap_or(0)`.
A bound this client cannot read now means the snapshot is stale immediately and
the next `admit` re-polls, which is the module's documented fail-closed rule;
the old arm would have read as fail-open the day the schema widened.
The two adapter lanes meet, and this is where most of the run's real merge
interactions were:

- capsule-server/Cargo.toml: both lanes added testcontainers dev-dependencies,
  so the textual merge produced a duplicate key. One entry, both rationales,
  features = ["valkey"] unioned onto the workspace pin's postgres/redis.
- capsule-server/tests/valkey.rs: #402 split the conformance Harness into
  CohortHarness + Harness; #403's harness implements the pre-split shape. Split
  the impl.
- boot.rs: each lane wired its own half and left the other refusing, with two
  unit tests asserting contradictory first refusals on the same environment. The
  durable arm now demands DATABASE_URL, opens the pool, checks the schema, and
  proves Valkey answers PING — and still refuses, naming #446, which owns five
  durable ports neither lane landed. The two contradictory unit tests became one
  container case that carries both lanes' assertions.
- .config/nextest.toml: both lanes populated the same empty container group;
  union of the prose and all four overrides.
- Cargo.lock regenerated rather than hand-resolved.
…ounds nothing

The report route's own docs flow into `openapi.json`, and the status paragraph
that landed with the M3 correction had not been regenerated.

While there: the route claimed the cost of verifying before charging was "one
Ed25519 verification per unsigned request, which the router's body-size limit
already bounds". That limit is 32 MiB and bounds nothing useful, which is what
the review said. Replaced with what actually runs — every field capped first,
then a claimed-origin intake budget before the store read and the verify, then
the policy budgets after it — and with what is still unbounded, bytes parsed
per request, pointing at #478.
Merge interaction: #404 made X-Capsule-Protocol a typed required parameter on
every generated operation, so #434's escrow orchestration and 401-retry tests —
written against the pre-#404 client — no longer compiled. Regenerating
capsule-server/openapi.json with mise run openapi-kynos reproduces the merged
document byte-for-byte, so the contract is right and the call sites were the
gap. Updated them to the convention #404 established (PROTOCOL_VERSION as the
first argument), and mapped the two status variants the handshake added:
FetchEscrowError::Status400 and StoreEscrowError::Status426. Both map onto the
existing Malformed variant carrying the server's stable code rather than
widening RecoveryError, which #434 owns and did not declare non_exhaustive.

capsule-sdk/src/client.rs takes both improvements to one function: #404's
routing through net::http_client (which carries the handshake on every request)
memoized in #434's OnceLock, since http_client builds a fresh pool per call.
…surface

Review finding 34. The codec unit proves a capability is unreadable to the
session verifier and a session token to the capability codec — token confusion.
Nothing proved the *routing*: that only three operations mount `ReadBearer` and
every other secured operation still takes `Auth<AccessToken>`, so a capability
presented on a write is refused by the scheme rather than admitted as some
default principal.

Asserted on the wire across six operations — album provisioning, roster
publish, capability mint, upload creation, quota and the moderation record —
using the album the capability is actually for, so nothing passes merely by
naming the wrong one. The same token is then admitted on the read it is for,
which is what makes the six a test of the surface rather than of a token that
had gone bad.

Also: `federation::on_peer_blocked` now says in its own docs that it is owed a
caller, the way `boot` names what #403 owes. The refusal a block causes is
automatic — `blocked_at` is consulted at four boundaries — and the cascade is
not; it adds publication, which is why it is a separate step, and a reader
should not assume it runs by itself.
Merge interactions, all from #459 landing a fourth ceremony store against a base
that predates #402's Harness split and #403's counter work:

- store/conformance.rs: #459 added the OIDC cases to the pre-split run_all, and
  the textual merge dropped them into run_all_cohorts, where the accessor does
  not exist. Moved to run_all, whose Harness declares it.
- store/memory.rs, boot.rs: unions — the new accessor without #402's relocated
  advance; both BootError variants; both boot helpers; #459's OIDC guard ahead
  of the two probes it must survive.
- counter/mod.rs: #403's scope() predates #459's three new CounterKey variants,
  so the match was non-exhaustive. Added the arms; the two unit variants are one
  global bucket each and share a named GLOBAL_SCOPE segment rather than an empty
  one, since the backend key is capsule:counter:{kind}:{scope}.
- counter/tests.rs: #403 rewrote the file to delegate to conformance and removed
  the local key/budget/at helpers that #459's added cases call. Restored them
  next to the cases that use them.

dependencies.md takes #459's HTTP-client row and its new OIDC relying party row
alongside #443's spargen row; capsule-server/Cargo.toml keeps both new
dependencies.
Merge interaction: #458 added a `format` field to openapi::Member, and #404's
RETRY_AFTER literal — added on a base without it — no longer initialized the
struct. retry_after is counter::unix_seconds' u64, so it takes
format: Some("uint64"), which is what #458 added the field to record.
Regenerated capsule-server/openapi.json with mise run openapi-kynos: five
retry_after schemas gain the format, and nothing else moves.

boot.rs is documentation on both sides; HEAD's prose supersedes and enumerates
no adapters, so #458's membership store is not dropped from any claim — the
composition test already exercises PostgresMembership.
Merge interaction between #405 and #408, visible on neither branch alone. #405
refuses an upload whose `created_by_user` is not the authenticated caller, so a
writer member cannot file an asset into somebody else's album attributed to a
third account. #408's push case drives a real `Workspace`, which mints its own
account id at creation, while the fixture signed in as `support::user()` — two
identities that only had to agree once both changes were in one tree.

The library's id is the one that cannot move: `Workspace` exposes `user_id()`
and no setter. So the seeded credentials are re-pointed at it, and the album
owner and the directory device follow. Asserting the ladder works while signing
as somebody else is the thing #405 exists to refuse.
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 629949e
Status: ✅  Deploy successful!
Preview URL: https://12fe9f0e.capsule-22k.pages.dev
Branch Preview URL: https://chore-api-audit-and-roadmap.capsule-22k.pages.dev

View logs

…one route

Review finding 35, and the commit that introduced the ceiling said the wrong
thing about it. `store::admissible` bounded a token against `MAX_TOKEN_TTL` and
against its own grant's deadline, but nothing anywhere bounded that deadline:
the ninety-day cap lived only in `mint_capability`'s parsing of
`renewable_until`. Unreachable today, because the route is the only caller of
`issue()` — and the stated goal was "shared by every adapter, so an adapter
cannot re-derive the check differently", which a route-side check is not.
past it.

`MAX_GRANT_LIFETIME` moves to `federation::store` beside the rules it belongs
with, and `admissible` refuses a record whose `not_after` is further from its
`issued_at` than the ceiling allows. The route keeps its own `400`, which is
now a better diagnosis of the same refusal rather than the only thing enforcing
it.

The conformance case issues **directly through the port**, bypassing the route
entirely, because that is the property under test. It also pins the boundary as
inclusive — exactly ninety days is admissible, a mistyped year is not — and
that a refresh cannot walk past the ceiling, since the successor carries the
deadline unchanged and the cap is therefore fixed at the original mint rather
than re-measured per token. Neither of the two cases beside it covered this:
one bounds a token's life, the other a successor against its predecessor.
…l that it is unknown

Review finding 36. Requiring `reported_user` to resolve was right — an
unresolvable report is a permanent orphan row nobody can act on, which was M1's
actual point — but answering the failure with a distinct coded `404` was not.
It manufactured an account-enumeration oracle out of a check that never needed
one: a peer could walk identifiers and read existence off the status line.

The earlier defence was that it is bounded to operator-pinned peers. That is
accurate and insufficient. A pinned key can be compromised, and a peer can be
adversarial toward its own users while remaining an operator's legitimate
partner; "trusted enough to report" is not "trusted with enumeration". This
codebase treats exists-versus-does-not as a first-order defect nearly
everywhere else — `enroll.rs`'s indistinguishable code refusal, the album
ceremonies' "not yours is not found", the blob authority's `404`/`403`
boundary — and this route was the exception.

Intake now answers `202 Accepted` either way. A resolvable `reported_user` is
filed; an unresolvable one is dropped with a `tracing::warn!`, which is the
"log so an operator can act, never tell the asker" pattern `enroll.rs` already
uses. The queue stays clean and no oracle appears. Probing is not free: every
budget is charged before the account is looked at, so a peer sweeping
identifiers spends its allowance and the operator sees the warnings.

The test asserts the two answers have the same status *and* the same body
shape, differing only in the fresh `report_id` every acceptance carries — a
body that were empty or short a field would be the oracle back again one field
along — and that only the resolvable report reached the store.

`error.moderation.report_unknown_user` had no other user and is removed from
the catalog; the `404` leaves the contract with it.
The conflicts are genuine federation-versus-everything-else interactions:

- counter/mod.rs: #472 added four federation CounterKey variants and their
  budgets against a base that had neither ceiling() (#403's partition work) nor
  scope() (#403's Valkey key). Both matches were non-exhaustive. Added the arms,
  and four ceilings the module was missing: PEER_ORIGIN for the two peer-keyed
  variants, and FEDERATED_REPORTS/FEDERATED_INTAKE sized from #472's own
  budgets — the intake key is charged before authentication, so its ceiling is
  the only bound on an attacker-chosen key space and it is sized like the other
  caller-supplied partitions.
- boot.rs: ServerInfo gained a conditional builder call on each side. Both apply;
  with_oidc and with_federation compose before the Arc.
- tests/support/mod.rs: Fixture::build grew a parameter on each side
  independently — (provider, counter_ceiling) from #407/#432 and federation_url
  from #472. One signature carrying all four, and every constructor updated.
- tests/sdk_client.rs: both sides appended a test at the same point; union.

capsule-server/openapi.json regenerated with mise run openapi-kynos for the
federation routes.
The last lane, and the one that consumes everything. Interactions:

- capsule-core/src/lifecycle/open.rs: #463 replaced the local fast_params() fn
  with a shared FAST_PARAMS const and updated every call site; a test added
  later on another branch used the old spelling.
- capsule-server/tests/sdk_client.rs: #463's second App enumerates every Modules
  field, and three contexts landed after it was written (#405 membership,
  #407 oidc, #406 federation).
- capsule-sdk/src/recovery/mod.rs: #463 solved the #404 escrow-status problem
  independently and better - it widened RecoveryError::Unexpected to carry a
  code, and its own test asserts 426 maps there. Its arms are authoritative;
  the ones added when #404 was merged here are removed as duplicates.
- capsule-sdk/src/push.rs: a criss-cross left the same doc paragraph on both
  sides in different positions. One copy, in HEAD's ordering.
- capsule-server/Cargo.toml: #463's reworded tempfile rationale over the
  migration and testcontainers blocks this branch already carried.

capsule-core's test-support feature and capsule-e2e's dev-only dependency on it
are preserved exactly: the caller-chosen Argon2 cost stays out of production
builds, and resolver 3 keeps the feature off cargo build --workspace.

Six E2E cases are ignored against #467 - see the following commit.
Counted from the merged table rather than summed from the lanes, which is the
whole reason this branch exists. Both numbers agree on the total and disagree on
everything else: all twenty lanes recorded a row delta of zero and were right to,
yet the area and status breakdowns had all moved, because what the lanes changed
was the state of rows that already existed.

  205 rows, unchanged
  area    87/75/43 ACTIVE/RETIRED/MIXED  ->  90/71/44
  status  95 done, 60 done*, 28 ready, 9 part, 9 blocked, 4 post-v1
       -> 111 done, 62 done*, 16 ready, 9 part, 3 blocked, 4 post-v1

Six rows left the blocked list in one programme, and the prose now names which
and why. The rawshift gate row said "stabilizing, unconsumed"; rawshift-image
0.1.1 is consumed from crates.io, so the row records the three sub-gates that
actually remain (#437, #438, #444) instead.

ROADMAP.md gains the two rows check-docs-truth was asking for, capsule-e2e and
capsule-server/migration, and eight States move to what the tree proves with the
gate that proves it. capsule-server is stabilizing rather than rebuilding, and
its Notes say plainly that serve --memory runs while a durable serve refuses
naming #446 - a package row must not claim a deployment this tree cannot produce.

The four crates the invocation froze were audited on this head. capsule-core-ffi,
capsule-wasm and capsule-i18n are frozen: one public path per type, no dead
surface behind their binding boundaries. capsule-core is not, and its row says
so - ten pub mod + barrel sites survive the #399 freeze, filed as #480.
… group

Two merge interactions between #443's reference generator and the lanes that
landed after it, both found by check-docs on the integration head and by nothing
before it.

The generator refuses composition, because a property table cannot express a
union. #407 then added an optional object - AuthEndpointsResponse.oidc - and
OpenAPI 3.1 has no nullable keyword, so every Option<T> over a struct is spelled
anyOf: [{ $ref }, { type: 'null' }]. That is a composition by the letter and not
by intent: there is one real branch, and it flattens perfectly well once the null
arm is dropped. nullableBranch recognises exactly that idiom and typeOf renders
it as `T | null`. The narrowness is the point and is tested three ways: a union
of two real branches beside null is still refused, and a genuine union nested
inside an optional is still caught.

refuses on purpose so an endpoint family cannot silently fail to publish.

doc-check-rust is a third of the same kind: the gate arrived with #399 and
capsule-i18n's plural module with #414, so the two never met until here. Two
public doc comments linked to private items; they are prose now, not links.
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