[CHORE] Integration head: merge the programme, then audit the API and recount the trackers - #479
Open
justin13888 wants to merge 299 commits into
Open
[CHORE] Integration head: merge the programme, then audit the API and recount the trackers#479justin13888 wants to merge 299 commits into
justin13888 wants to merge 299 commits into
Conversation
…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.
…9' into feat/core-notify-alert-classes-411
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 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.
…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.
…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.
…server-federation-406
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.
Deploying capsule with
|
| 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 |
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andROADMAP.md's per-packageStatecolumn can only be made true against a tree that holds all ofit. 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 targetschore/merge-v1-head-397like every other lane.All twenty lanes are merged, and the bookkeeping is done against the finished tree: the
SLICES.mdrecount, theROADMAP.mdstates, and the API audit over the four crates theinvocation froze.
The merge table
Each lane merged at the exact SHA below. "Trivial" means the merge produced no conflict.
docs/reset-trackers-and-add-roadmap-39809b95705chore/retire-capsule-wire-400be5a12abchore/delete-core-import-media-bucket-4238486a5c3chore/close-ci-gate-holes-416b3c2c754fix/i18n-plurals-and-swift-detector-4148c191fb4chore/freeze-capsule-core-api-399f508bf1afeat/core-notify-alert-classes-411eb2a4455fix/sdk-escrow-route-and-401-retry-408da86f045feat/media-rawshift-still-decode-410a0d5a6e0feat/sidecar-unsigned-migration-4127805ba5bdocs/reference-generation-41500f8dce4feat/cli-help-catalogs-show-repair-413b219db35feat/server-binary-config-operator-commands-4014dc14504feat/valkey-adapters-403e05c633eCargo.lock)feat/postgres-adapters-402d2a789d7fix/protocol-headers-every-route-404032b6af2feat/oidc-relying-party-407f1ab7d2afeat/server-album-membership-4055fcd4a6cfeat/server-federation-406b8babdb3test/e2e-cases-409b0fbfa1cMerges 1–18 were made at each branch's
origin/head; 19 and 20 at the fixed points therun recorded. #472 already contained #458 at
5fcd4a6c, so that side merged empty as expectedand 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:
capsule-server/Cargo.tomlduplicatetempfilekey ([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 samedev-dependency with its own rationale and the textual merge kept both, so the manifest would
not load.
capsule-server/Cargo.tomlduplicatetestcontainerskeys (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.Fixture::buildgrew 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 carryingall four, every constructor updated.
openapi::Membergained 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.CounterKeygained 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), leavingas_str,ceilingandscopenon-exhaustive. [FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472 never sawceiling(), so four ceilingshad to be supplied — see Decisions taken.
tests/valkey.rsimplements 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).Modulesgained 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.fast_params()becameFAST_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:
capsule-server/openapi.jsonreproduced the merged document byte-for-byte, so the contract wasright and the call sites were the gap; two new status variants made two matches non-exhaustive.
doc-check-rustmetcapsule-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 doccomments linked to private items. The gate and the file existed on no common branch.
AuthEndpointsResponse.oidcisan optional object, and OpenAPI 3.1 spells that
anyOf: [{ $ref }, { type: 'null' }]— whichevery
Option<T>over a struct emits. Taught the generator that one idiom; a union of two realbranches is still refused, and so is a genuine union nested inside an optional.
/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 generatorrefuses on purpose so an endpoint family cannot silently fail to publish.
The one that needed a decision rather than a fix:
Durableboot 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 itsown 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 ownsfive 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-coremints a library's account id locally and writes it into every signed manifest ascreated_by_user;capsule-servermints its own at registration. #405 refuses an upload whosecreated_by_useris not the authenticated caller. Neither branch had the other, so neither couldsee 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'sverify_assetstep 6and
capsule-server'sdirectory::project_version. What is missing is the client seam that wouldlet 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 paperedover, and
capsule-server/tests/sdk_client.rsreaches the same property only by re-pointing aseeded 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.rsclaimedops.rsdoes not make the same comparison (it does — the check was removed there and restored, and the
comment was never updated), and the
rawshiftgates 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 forgen-bindingsandcheck-web).All pass on the head of this branch.
cargo check --workspace --all-targetsmise run format-check-rustmise run lint-check-rustmise run doc-check-rustmise run build-rustmise run i18n-check/i18n-guardmise run openapi-check-kynosmise run architecture-checkmise run license-checkmise run translate-readme-checkmise run build-check-wasmmise run build-ffi/lint-check-ffimise run gen-bindingsCARGO_TARGET_DIRunset)mise run verify-examplesmise run cli-surface-checkmise run test-rustCAPSULE_TEST_POSTGRES=1,--lib --test-threads=1CAPSULE_TEST_VALKEY=1,--lib --test valkeymise run check-docs-truthmise run check-docsmise run check-mdmise run check-webbun installwas run in this worktree first —node_moduleswas absent)cli-surface-checkis the one to read. Every lane in this run reported itunavailablebecause 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
--libincluded: the adapter-parity suites live in#[cfg(test)] mod testsinsidesrc/**/{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:96carries anunused-import warning under
--all-targets. It is present at the base99dd4bc8and on both sidesof every merge (checked with
git show), and is invisible tolint-check-rust, which omits--all-targets(issue #474). Not turned on here.Not run:
check-kotlin/check-swiftand the Android build. CI'sBuild Android appfails onevery branch in this run and on their bases; nothing here touches those trees.
SLICES.md— counted, then cross-checkedThe 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:
was right to — [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 ("the lane adds no row") and [FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472 ("row delta: 0") say so outright. No lane
added or removed a row.
existed:
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
blockedrows areS-C47,S-C49andS-P4; six left thelist in one programme and the prose now names which and why.
API audit — the four crates the invocation froze
capsule-core-fficapsule-wasm#[wasm_bindgen], no duplication.decodeLqip/WasmLqipImageare the one exportcapsule-webdoes not yet import — Rust half done, viewer half notcapsule-i18nplural); no duplication.format_message/format_message_inhave no caller outside the crate — every consumer goes throughBundle::formatcapsule-corepub(crate) mod+pub use, but ten sites still pairpub modwith a re-export of the same items, so ~110 types resolve at two paths. Both paths are live:capsule-wasmimports the long form,capsule-clithe short oneFiled as #480 with the ten sites enumerated.
A correction to the brief. The invocation named
cbor,crypto,drop,sharing,validation,lqipandclient_buildas cargo features whose no-default-features surface hadto 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 notcontain these features". They are the ungated
pub moddeclarations incapsule-core/src/lib.rs,and what keeps them in the sealing build is the absence of a
#[cfg(feature = …)]. The surface isintact — plain
--no-default-featurescompiles clean — and the real set is one larger:derivative_formatis also ungated, deliberately, because the crates that receive aDerivativeManifestbuild withdefault-features = false. The three consumers arecapsule-wasm,capsule-server, and the directwasm32-unknown-unknownbuild inmise run build-check-wasm.ROADMAP.mdrecords it as modules.ROADMAP.mdcheck-docs-truth's roadmap check named two packages with no row —capsule-e2eandcapsule-server/migration, both new in this run — and both now have one. EightStatecells movedto what this tree proves, each with the gate that proves it.
capsule-servermovesrebuilding→stabilizing, and itsNotessay plainly thatserve --memoryruns while adurable
serverefuses naming #446, so the row cannot be read as claiming a deployment this treecannot produce.
Risks and rollout
The risk is concentrated in the resolutions that were not mechanical: the
Durableboot arm,capsule-sdk/src/push.rs, the escrow error mapping, theCounterKeyceilings, and the referencegenerator'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.rsboot case proved the durable arm reached Valkey; under the mergedordering 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 ispub(crate).Both reductions are recorded in the code, not only here.
Two things deliberately not done, because they belong to another lane:
RecoveryErrorwas not widened for server: the protocol headers the design puts on every route are on four operations #404's new statuses at merge time (sdk: recovery calls a route the contract does not serve, and three smaller gaps #408 owns it and did notdeclare it
#[non_exhaustive]). [TEST] Land the bounded E2E cases in a capsule-e2e crate #463 had independently widenedUnexpectedto carry a code andits own test asserts that mapping, so its arms are what survive and the interim ones are gone.
Durablearm is not completed. That is server: Postgres adapters for the remaining durable ports #446's scope; completing it here would mean inventingwiring neither adapter lane wrote.
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
Taken: merge each terminal lane tip by merge commit in the recorded dependency order, at its
fixed-point SHA, committing each merge separately.
Rejected: rebasing the lanes onto one another, which would rewrite eighteen published
branches and destroy the correspondence between each lane's PR and its commits.
Reverses: nothing.
Taken: resolve
SLICES.mdrow conflicts as a per-row union — each lane edited only itsown slice ids, so a conflict there is two disjoint edit sets meeting in one file.
Rejected: choosing a side, which silently drops the other lane's recorded status.
Reverses: nothing.
Taken: where a lane's row or detail block asserts a precondition that a later-merged lane
removed (
S-B1/S-B5/S-B13/S-B14"blocked, Rawshift is unconsumed";S-Z8/S-Z9"ready"), take the landing lane's text.
Rejected: union of both, which would leave the tree asserting a slice is blocked by a
condition the same tree removed.
Reverses: nothing.
Taken: resolve
capsule-server/openapi.jsonandCargo.lockby regeneration, never astext.
Rejected: hand-merging either.
Reverses: nothing.
Taken: the
Backends::Durablearm demandsDATABASE_URL, opens the pool and checks theschema, then proves Valkey answers
PING, then refuses naming server: Postgres adapters for the remaining durable ports #446.Rejected: (a) completing the arm, which is server: Postgres adapters for the remaining durable ports #446's scope and would mean inventing wiring
neither lane wrote; (b) keeping either lane's "the other half is missing" refusal, each of which
the other lane falsified.
Reverses: the refusal text both server: Postgres adapters and a conformance suite for every durable port #402 and server: Valkey adapters for the auth-state and upload-session ports #403 shipped.
Taken: server: the protocol headers the design puts on every route are on four operations #404's two new escrow statuses map onto
RecoveryError::Malformedcarrying theserver's stable code.
Rejected: adding a variant to
RecoveryError, which sdk: recovery calls a route the contract does not serve, and three smaller gaps #408 owns and did not declare#[non_exhaustive], so widening it in an integration merge is a breaking change made by thewrong lane.
Reverses: nothing.
Taken:
CounterKey's two unit variants share a namedGLOBAL_SCOPEsegment in the backendkey, since
as_str()already distinguishes their kinds.Rejected: an empty scope segment, which leaves a key ending in
:and invites a secondspelling later.
Reverses: nothing.
Taken: re-point sdk: recovery calls a route the contract does not serve, and three smaller gaps #408's push-case credentials at the library's own user id.
Rejected: relaxing server: server-side album membership (S-C51), which the blob 403 and album writes wait on #405's authorship rule, which is a real security property; and changing
the shared fixture's seeded account, which would ripple through the whole server suite.
Reverses: nothing.
Taken: publish
SLICES.md's figures from a count of the merged table, and use the summedlane deltas only as a cross-check — reporting that they agree on the total and disagree on every
breakdown.
Rejected: publishing the sum, which would have reproduced the stale figures exactly and
looked correct doing it.
Reverses: nothing.
Taken:
#[ignore]the six E2E cases core: a Workspace cannot open as a server account — no constructor from a recovered master key, no way to bind the account id #467 blocks, each with the mechanism written at thecase and the issue named.
Rejected: (a) inventing
Workspace::create_from_master— new public API on a frozen crate,outside this lane's manifest, and core: a Workspace cannot open as a server account — no constructor from a recovered master key, no way to bind the account id #467's own stated fix shape; (b) relaxing server: server-side album membership (S-C51), which the blob 403 and album writes wait on #405's check, which
the design supports and
verify_assetenforces independently; (c) leaving them failing, whichmakes the branch permanently red and hides the next real failure.
Reverses: six cases [TEST] Land the bounded E2E cases in a capsule-e2e crate #463 landed green, on a base that did not contain server: server-side album membership (S-C51), which the blob 403 and album writes wait on #405.
Taken: supply four
ceilingsconstants for [FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472's federation counter keys, derived from[FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472's own budgets —
PEER_ORIGINfor the two peer-keyed variants, andFEDERATED_REPORTS/FEDERATED_INTAKEsized as caller-supplied partitions because the intake key is charged beforeauthentication and its ceiling is the only bound on it.
Rejected: one shared ceiling, which the module's own docs call a shared fate.
Reverses: nothing —
ceiling()did not exist on [FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them #472's base, so these keys had never met it.Taken: teach the reference generator the OpenAPI 3.1 nullable-object idiom
(
anyOf: [{ $ref }, { type: 'null' }]) and render itT | null.Rejected: (a) removing the composition guard, which exists because a property table cannot
express a union; (b) hand-editing
openapi.json, which is generated.Reverses: nothing. A union of two real branches is still refused, and so is a genuine union
nested inside an optional — all three cases tested.
Taken: record
capsule-coreasstabilizing, notfrozen, and say why in itsNotes.Rejected:
frozen, which the ten survivingpub mod+ barrel sites would have made false.Reverses: nothing.
Taken:
capsule-servermovesrebuilding→stabilizing, withNotesstating thatserve --memoryruns and a durableserverefuses naming server: Postgres adapters for the remaining durable ports #446.Rejected: a state implying a runnable durable deployment, which this tree cannot produce by
my own boot-arm decision above.
Reverses: nothing.
Taken: correct the brief's premise in
ROADMAP.md— the seven names are ungated modules,not cargo features — and record the surface as modules.
Rejected: recording a feature list that does not exist and that
cargo check --features …rejects.Reverses: the framing the invocation supplied.
Unresolved review notes
(none yet)