[FEAT] Server-side album membership (S-C51) - #458
Open
justin13888 wants to merge 40 commits into
Open
justin13888 wants to merge 40 commits into
justin13888 wants to merge 40 commits into
Conversation
…ix/protocol-headers-every-route-404
…ix/protocol-headers-every-route-404
…rceptor pair The design puts three request headers and three response headers on every route; the server declared the request half on four upload operations and never sent the response half at all. A Kynos ApiError has no response-header seam, so routes/upload.rs rode X-Capsule-Protocol-Min/-Max as problem extension members while capsule-sdk reads them from headers and got None. The seam is on the interceptor. negotiation.rs adds two: Negotiation, mounted router-wide outside the body-size limit, attaches the window to every response the chain produces, errors and short-circuits included; ProtocolGate, on a Group, reads the three request headers and refuses 426 or 400 before the handler runs. Both read the one UploadPolicy window, which gains the advisory min_client_build. The group holds the four upload session operations that enforced the handshake per route until now; routes/upload.rs loses that duplication, and the 426 keeps no window members in its body. openapi::describe_negotiation_headers files the three response headers under every response of every operation, since Kynos describes an interceptor's headers on success responses only. The test fixture's client sends the handshake on every request with raw() for its absence, and conformance.rs gains a document census and a document-driven wire census that pin the gated and exempt sets. Refs #404
boot::assemble built the upload policy from UploadPolicy::default() regardless of PROTOCOL_MIN and PROTOCOL_MAX, so a deployment that narrowed its window published one range on /.well-known/capsule/server-info and enforced another on POST /v1/upload. The policy is now built from the configured window, which is also what the negotiation interceptors advertise and refuse against. The new boot test reads both back through the surface. Refs #404
Every gated operation requires X-Capsule-Protocol and refuses without it. The shared reqwest client behind the generated REST client now carries that header and X-Capsule-Crypto-Suite as defaults, from the build's own constants, so the generated operations and the hand-written paths over the same transport send them with no per-call argument. protocol_headers() is public so the SDK's other transports can carry the same handshake from the same source. Refs #404
The ProtocolGate group now holds every non-exempt operation; GET /v1/version,
the four /.well-known/capsule/* records, the three /s/{opaque_id}* reads and
the two /d/{opaque_id}* guest deposits stay on the router and carry only the
response headers. The census in tests/conformance.rs asserts the gated set is
exactly the complement of the pinned ten, the coverage walk produces the 426
and 400 every gated operation now declares, and one representative route per
module is refused before a credential or body is read. The boot tests that
register and sign in send the handshake, as every client does.
Refs #404
net::http_builder installs X-Capsule-Protocol and X-Capsule-Crypto-Suite as default headers once; http_client builds it and dial_client adds its connect timeout on top of the same builder. AuthClient::new, the sync consumer, the generated client behind AuthenticatedClient and the CLI's version probe all obtain their client there, so no SDK request reaches a gated route without the handshake. The document now declares X-Capsule-Protocol required on every gated operation, which puts the protocol date first in each generated signature; the SDK's callers pass the build's own constant, the same value the transport sends, and the sync consumer maps the 426 the feed can now answer. Refs #404
reqwest::ClientBuilder is already must_use; clippy's double_must_use refuses the second attribute under the repo's -D warnings. Refs #404
The key-free server cannot read the MLS roster, so it has had no membership fact to gate the blob route or the album write routes on. `crypto::membership` is the artefact that gives it one: a full-roster document with a strictly monotonic `roster_version` and the AMK epoch it reflects, signed by a device in the album owner's published device directory and verified the way the upgrade intent is, with the added rule that a revoked device may not attest. It lives in `capsule-core` because a client signs it; the server only verifies. One public path, `capsule_core::crypto::membership`, under the #399 freeze.
Deploying capsule with
|
| Latest commit: |
5fcd4a6
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ca21ec55.capsule-22k.pages.dev |
| Branch Preview URL: | https://feat-server-album-membership.capsule-22k.pages.dev |
…ration `membership::MembershipStore` holds the one fact the key-free server has about who may read and write a shared album: the consequence of the owner-signed roster. `apply_roster` compares versions and replaces the roster in one critical section — a mutex in memory, a per-album transaction lock in Postgres — and a member the owner removes keeps a row marked with the version and epoch at which they vanished, which is what lets the blob route render `403` for a former member while every stranger still sees the unknown-address `404`. Migration ordinal 5 adds `album_rosters` and `album_members`; the compiled-in `EXPECTED_MIGRATIONS` gains it; one conformance suite runs against both adapters, the Postgres case under the container gate; the boot compose test constructs the Postgres adapter beside the other four. The context is wired into `App` and the fixtures so the roster route can follow without touching the composition again.
…eat/server-album-membership-405
… window threat-model/validation.md scopes the 426 to a write and says reads of any past version succeed. The gate is now two: ProtocolGate on the group of non-safe operations refuses a grammatical protocol date outside the window with 426, and ProtocolReadGate on the group of GET and HEAD operations admits any grammatical date and refuses only a missing or malformed handshake with 400. Because an interceptor's declaration is its type, a read now declares the 400 alone and no 426 it never renders; the census asserts exactly that, the coverage walk and the per-module table follow the split, the 413 loop and a new 401 case assert the window headers on refusals the gates did not make, and the feed's dead 426 arm leaves the SDK's sync consumer. Refs #404
PROTOCOL_MIN and PROTOCOL_MAX defaulted to the single day capsule-core speaks, collapsing the window a fresh deployment accepts to one date, and neither was checked for shape, so 2026-6-1 sorted before 2026-12-31 for the wrong reason. Both now default to the policy's year window, parse as strict YYYY-MM-DD dates, and may be equal. MIN_CLIENT_BUILD joins them, validated as MAJOR.MINOR.PATCH and handed to the upload policy, so the advisory X-Capsule-Min-Client-Build on every response is the operator's value rather than the crate default. With both validated at the boundary, an unencodable window value is a programming error and the response encoder says so. Refs #404
Every transport constructor that accepts a reqwest::Client names net::http_builder as its source; a client built any other way sends no protocol handshake and every gated route refuses it. Refs #404
Every gated route now refuses a request without X-Capsule-Protocol, and the hand-written web auth client sent none, so sign-in, registration, refresh and every authenticated call from the browser were refused. All five request builders send the protocol version this client is written against, restated from capsule_core::crypto::primitives::PROTOCOL_VERSION because the browser holds no Rust and the wasm surface does not export it. Refs #404
Four `error.album.roster_*` codes for `PUT /v1/albums/{album_id}/roster`
— malformed, attester, not_found, stale — in the source catalog only,
as every other `error.*` code the server emits is; the per-platform
files are the generator's output.
The one way the key-free server learns who may read and write a shared album. The owner account PUTs a `SignedAlbumRoster` as base64 canonical CBOR; the route refuses anything decidable from the request alone as `400`, answers a non-owner with the album ceremonies' `404`, verifies the attester against the owner's published device directory exactly as the upgrade ceremony verifies its proposer (`403`), and applies the roster through the membership port — `200` with `replayed` for the same bytes, `409` carrying `current_version` for a version or epoch that does not supersede the held one. JSON with base64 rather than an `application/cbor` body so spargen generates the operation and the SDK needs no hand-written request. The route is mounted inside the protocol-gated group; the conformance walk drives every declared response; invariant 33 and the idempotency row land with it because the endpoint census resolves docs against the regenerated document.
api.ts is hand-written, so the header could silently go missing again. A recording mock of the global fetch drives the five request builders and checks X-Capsule-Protocol equals PROTOCOL_VERSION on each, and PROTOCOL_VERSION is compared with the literal in capsule-core/src/crypto/primitives.rs read at test time, so the restated constant cannot drift from its source of truth. Refs #404
`WriteAuthority::album_write_access` is keyed on the caller and answers
whose album it is: `Writable { owner_id, role }` for the owner or for a
writer on the album's roster, one `Denied` for a reader, a former
member, a stranger and an unprovisioned id. `ProvisionedAuthority`
consults the membership store for the widening `S-C25` deferred.
Every write files under the album owner's namespace, because the
owner's feed is the one every member's devices read, and bills the
uploader, who spent the bytes. The upload route takes the namespace
from the authority and lets a declared `owner_id` only agree with it;
the ops route files a member's op under the owner; adoption stays in
the link owner's own album; finalization re-asks for the uploader, so
a member unshared mid-transfer is refused where a closed album is.
…-album-membership-405
…eat/server-album-membership-405
`MembershipAuthority` replaces `OwnedAssetAuthority`: an account reads the blobs of its own assets and of every album whose current roster names it, in either role; an account the roster once named and no longer does is `BlobReadAccess::Revoked`, rendered as `403 error.blob.access_revoked` — the authorization-change signal the download contract describes, which `S-C39` left unwritable for want of exactly this fact. Everyone else still gets the `404` an unknown address gets, body and headers alike, and the authority is still asked before every `410`, so a former member learns nothing about holds or deletions either. `BlobReference` carries the asset's `album_id` so the decision comes from the same read that found the reference. Bytes shared across unrelated owners are still decided from the first live reference, which this widens from a wrong `404` to a possible wrong `403`; filed as #462 rather than fixed here. Closes the S-C39 done-when; the download-sync and authorization docs describe what the server now decides.
…c?album_id= A member has to learn the addresses in a shared album before the blob route can serve them, and the owner's feed is the owner's. With `album_id` the feed pages the **owner's** sequence filtered to one album, for the album's owner or any account on its current roster — positions stay per-album monotonic, as the client's anti-rewind mark requires, and the album's head is its own last entry rather than the owner's allocator. Unprovisioned, never-a-member and removed are one `403 error.sync.album_access_denied`, as the write routes answer. The cursor MAC now covers a `CursorScope` — the caller and, for an album page, the album — with the caller length-prefixed, so a cursor minted for one shape cannot be presented on the other. `AssetIndex` gains `album_feed_page`/`album_head_seq` in both adapters with one conformance case; the sync context gains the album and membership stores. Closes the S-C51 done-when.
…r scope `album_feed_page` and `album_head_seq` take the owner the album record names as well as the album, so the `(owner_id, album_id)` index serves both queries and a row another account filed under the same album id is not this album's; the route hands the owner over from the access decision it already made. The cursor module's header now states the scoped MAC input and why the version byte did not move; the sync doc names the position-gap disclosure the album page accepts; the route cases publish past the album's head so `has_more` is proved against the album and not the owner's allocator.
`AlbumClient::publish_roster` sends a `SignedAlbumRoster` — signed in
`capsule_core::crypto::membership` by the owner's device — as base64 of
its canonical CBOR on `PUT /v1/albums/{album_id}/roster`, and maps the
reply onto `PublishedRoster`. Orchestration only: the bytes the device
signed reach the wire verbatim, a refusal carries the server's `error.*`
code, and an echo for another album is malformed rather than a success.
`AlbumError::Status` gains `current_version`, read from the `409 error.album.roster_stale` problem body, so a caller can republish above the version the server holds instead of guessing; the album errors read as the surface's rather than provisioning's, and the wire test asserts the bytes are canonical and standard-base64 encoded.
5 tasks
`roster_version` was monotonic with no ceiling, which makes it a latch as well as an ordering: one publish at the top of the counter can never be superseded, and the album's membership is frozen for good with no recovery path — the store's comparison is the only ordering there is. `precheck` now refuses a version more than `MAX_ROSTER_VERSION_STEP` (16) above the held one, reading a held version of `0` for an album with no roster, so a first publish cannot latch it either. Both adapters inherit the rule; the refusal is `400 error.album.roster_version_leap` carrying the declared version, the held one and the ceiling, so the owner re-signs the same document at `current_version + 1` — a roster is a full statement, so the version is an ordering and never a count. The conformance suite gains the two cases that matter (the leap changes nothing; the next legitimate roster still applies) and the route suite pins the same over HTTP, absurd version and all.
`POST /v1/upload` has always asked the moderation port whether the caller's
standing permits a write; `POST /v1/albums/{album_id}/ops` never did, so a
suspended account could still tombstone, restore and re-point assets — every
write that does not move blob bytes. Inconsistent from the start, and no
longer only inconsistent: `S-C51` widened this surface from the album owner
to every writer member, so the accounts it admits are no longer only the
ones the operator suspended knowingly.
The check sits on the same seam the upload path uses, before the write
authority, the quota and anything stored, and renders the same
`403 error.moderation.account_suspended` — a distinct code from the quota
and permission refusals because the three send a client to three different
screens. A store that cannot answer refuses the write rather than admitting
it: an outage must not be a window in which every suspension is lifted.
The manifest envelope carries `created_by_user` and no server path ever
compared it to the authenticated caller. The manifest is stored verbatim and
served back as the asset's provenance, so nothing downstream re-derives the
author: the field is simply believed.
That was latent while `POST /v1/upload` and `POST /v1/albums/{album_id}/ops`
admitted only the album's owner, whose own id was the only one they could
plausibly write. `S-C51` admits every writer member, so a member can now
attribute an upload or a lifecycle op to a third account — the owner
included — inside the owner's own album.
Both surfaces now refuse the mismatch with `400
error.upload.envelope_mismatch`, the code every other self-contradicting
envelope field already uses: a `403` on these surfaces is about capability,
and this is a request contradicting what the request itself establishes.
Invariant 7's device half was already bound to the caller's own directory;
this is the account half of the same rule.
… not reclaim Two facts a reader of this port has to be told rather than infer. `pg_advisory_xact_lock(hashtext($1))` takes a single-argument advisory lock, whose 32-bit key space belongs to the whole database: another adapter, an operator's script or an unrelated application on the same instance can hash to an album's key. The cost is serialization and never correctness — equal album ids always hash equal, which is the only direction this lock is relied on for — and the repair, if contention is ever measured, is the two-argument form with a reserved class id. Removing a member reclaims nothing. Their uploads stay filed under the album owner and stay charged to their own quota, reachable now only by the owner, because the only path that releases an attribution is the refcount collector's, and it runs when the owner deletes the asset. Who should own that cost is a product question no design document answers, so it is recorded where it is observable and tracked as #473 rather than answered inside a storage port.
…efore it The sync cursor's MAC now covers the scope it was minted for — the caller, and the album when the page is an album's — so every cursor issued before this lands verifies against different input and is refused as inauthentic. Clients recover unaided: a refused cursor is a re-sync from zero, the same event a server key rotation already is. An operator still has to be told, because the observable consequence is a burst of full-feed reads on the first sync after the upgrade instead of the usual incremental pages. There is nothing to migrate — cursors are opaque, stateless and server-minted — and the version byte stays unchanged on purpose, so an old cursor fails as inauthentic rather than as malformed, which is the answer clients already act on.
`publish_roster` hand-wrote its request and response DTOs, mirroring what `provision` does. The repository's rule is that everything which parses or serializes is generated and only orchestration over generated calls is hand-written, and `publish_album_roster` is a plain JSON operation the committed contract describes completely — request body, success body, and both version-refusal problem shapes with their extension members. There was no generator gap to work around; the hand-written pair was simply a second description of a contract that already exists. The call now goes through `rest::Client`, built for the API root the album endpoint hangs off and carrying the same credential the transport already holds: the session's token provider, or the fixed bearer. Under a session a `401` is refreshed once and replayed, the way the sync feed does it, because a per-request provider's pre-flight refresh cannot cover a token revoked mid-flight. `provision` keeps its hand-written DTOs — that is a separate surface and a separate change. Two cases come with it: the `400 error.album.roster_version_leap` refusal surfaces its held version like the `409` does, and the request's method, path and required protocol header are asserted to be the document's.
`PostgresMembership::apply_roster` narrowed `roster_version` and `amk_epoch` to `BIGINT` before it took the lock and read the held roster, so a version past `i64::MAX` — precisely the wedge the version window exists to refuse — came back as this adapter's storage rejection instead of the port's typed `VersionLeap`. The in-memory store answered the refusal; Postgres answered a `500`. The shared conformance suite caught it on the container run, which is what that suite is for. The conversions now happen after `precheck`, so the port's rule is decided first and identically by both adapters. What reaches them is a version inside the window above one the column already held, which makes them a guard on an earlier check's promise rather than a decision of their own.
`POST /v1/albums/{album_id}/ops` admits exactly the five actions that do not
move blob bytes — delete, trash-restore, metadata-update, derivative-add,
derivative-replace — and none of them may carry a null prior hash. Every one
is a chain continuation, and `capsule_core`'s continuation builder
(`lifecycle::provenance::sign_lifecycle`) fills each field it does not
explicitly override from the chain head, so `created_by_user` travels down
from the asset's creator. On a shared album that is the point: a writer
member deleting an asset the owner created continues the owner's chain and
the record names the owner.
So the equality check added last round refused precisely the writes S-C51
exists to admit. It comes off this surface. It stays on `POST /v1/upload`,
which admits only `create` and `replace` — the two that mint freshly
authored ciphertext — where every client builder in the tree sets the author
to the signing account, the adopting web-upload drop explicitly so.
What binds a continuation to its caller is stated in the module docs rather
than implied: the bearer token, standing, write capability, and invariant 7,
which requires the named device to be in the *caller's own* published
directory — stronger than comparing an account id, because a device id in
your directory is not something another account can borrow. And what the
server cannot do is said too: it holds no keys and never parses the
manifest, so it cannot verify attribution at all. That is `verify_asset`'s
job on the client.
The test that hid this overwrote both fields to the member's own, so it
never exercised the shape a client sends. `bobs_delete` now inherits the
author and asserts it, and a named case pins the semantics: the owner
creates, the writer member deletes, the write is accepted under the owner's
authorship and reaches the owner's feed — while the same bundle carrying the
owner's *device* is refused, which is the binding that is real.
Refs #475.
The version-leap refusal echoed the declared version as an extension member. spargen lowers every integer in this contract as `i64` — it emits no `u64` anywhere, `format: uint64` and `minimum: 0` notwithstanding, as `RosterResponse.roster_version` shows — so a declared `u64::MAX`, exactly what the route and conformance suites submit, made the generated client fail to *decode* the problem. A decode failure is not a typed API error: the `code` and the `current_version` recovery hint were discarded and the caller could not tell the refusal from a network fault. Three changes, smallest first. `declared` stops being an extension member and rides the English `detail`, because it is the one number on that response the caller controls and the server therefore cannot bound — a human can read it and no decoder has to parse it. `MAX_ROSTER_VERSION` (`i64::MAX`) clamps the acceptance window, so every counter the server can hold or name is inside both what a `BIGINT` stores and what a generated client reads, by construction rather than by argument. And the extension table learns `format`, so the two counters that remain are described as the `u64`s they are — truthful, though on today's generator not by itself sufficient, which is written down where the table is. The clamp also closes the boundary the window left open: `saturating_add` alone collapsed `max_version` onto `current_version` at the top of the type, where every later publish would be stale — the original wedge, at the one value it could still occur. A held version above the ceiling can no longer exist, and the behaviour there is pinned anyway rather than left to be inferred. `RosterStaleProblem.current_version` had the same missing format and gets it in the same table edit.
`sign_lifecycle` built every delete, trash-restore and metadata-update with `..base.clone()`, inheriting `created_by_user` and `created_by_device` from the chain head while signing with the *current* device. The pair is not descriptive: `verify_asset` resolves the device inside that account's published directory (step 6) and verifies `device_sig` under that entry's key (step 8), so a record naming a device that did not sign it cannot verify — by anyone, ever. That broke the ordinary two-device case outright. Device B deleting an asset created on device A produced a manifest claiming A and signed by B, which every reader rejects. It equally broke every write by a shared album's member, which is why the widening in this branch had no client that could produce an acceptable continuation. Both fields are now re-minted per write from the acting account and signing device, exactly as all three create paths already do. Album authority is unaffected: it rides `write_sig` under the epoch's write-tier key at step 10, so naming the acting member as the record's author does not weaken the owner's album. The original creator stays where the append-only chain keeps it, in the `create` record at the head. The `client_version` distinction was right and is kept — it said this record's producer "not the creator's", which was the code stating the rule the two identity fields then contradicted. The field docs said "User who produced the asset"; they now say what the verifier requires and cite the coupling. No existing test encoded the inherited assumption — none had to move, which is how this survived. Two now pin it, and both fail if the inheritance is put back: a second device continues a chain and verifies, and a member continues the owner's chain and verifies under the member's own directory. Refs #475.
With the core fix in place, a legitimate continuation always names the account that signed it, so comparing `created_by_user` to the caller is once again a true structural refusal: it stops a writer member attributing a delete, restore or metadata update to the album's owner or to a third account, on a surface `S-C51` opened to every writer member. This reverses the removal one commit ago. That removal was correct about the symptom — the check refused writes the widening exists to admit — and wrong about the cause: the client was emitting an unverifiable manifest, not the server refusing a legitimate one. Fixing the client made the check right again. The module docs stop explaining why the comparison was dropped and state the settled rule instead: both identity fields name this record's signer, the server checks the account id and the device's presence in that caller's own published directory, and the signature itself is never checked here because the server holds no keys — that is `verify_asset`'s job on the client. The asset's creator lives in the chain's first record, and the owner's album is protected by the write-tier signature, not by the author field. `bobs_delete` carries both of Bob's identity fields again, now for the documented reason rather than as a convenience, and the member case asserts what stays the owner's: the namespace and the feed.
justin13888
added a commit
that referenced
this pull request
Sep 10, 2026
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Server-side album membership (
S-C51, closingS-C39's403): the owner-signed roster attestation incapsule-core, the membership port with in-memory and Postgres adapters (migration ordinal 5),PUT /v1/albums/{album_id}/roster, album writes widened to writer members, the blob route's403for a former member,GET /v1/sync?album_id=for members, andAlbumClient::publish_rosterin the SDK. Base is #447 (feat/postgres-adapters-402); #453 is merged in by sibling merge.Summary
capsule_core::crypto::membership—AlbumRoster/SignedAlbumRoster::{sign, verify}; canonical-CBOR signing bytes; a revoked device may not attest.capsule_server::membership—MembershipStore::{apply_roster, membership, current_roster},InMemoryMembership,PostgresMembership(per-album advisory transaction lock), one conformance suite,m20260902_000005_album_membership,EXPECTED_MIGRATIONS+= 1, boot compose test.PUT /v1/albums/{album_id}/roster(JSON, base64 canonical CBOR; 200/400/403/404/409+current_version/500), mounted in theProtocolGategroup; fourerror.album.roster_*codes.WriteAuthority::album_write_access(caller, album) → Writable { owner_id, role, … }; a writer member files under the owner's namespace and is billed as uploader; ops/upload/adoption/finalization call sites.BlobReference.album_id;MembershipAuthority(replacesOwnedAssetAuthority);BlobReadAccess::Revoked→403 error.blob.access_revoked; never-members still get the byte-identical404.AssetIndex::{album_feed_page, album_head_seq}bound to(owner, album);CursorScopein the cursor MAC;GET /v1/sync?album_id=;403 error.sync.album_access_denied.SDK:
AlbumClient::publish_roster,PublishedRoster,AlbumError::Status.current_version.Docs:
authorization.md(Album Membership on the Server),import/download-sync.md,threat-model/validation.md(invariant 33 + idempotency row),api-surfaces.mdrow;SLICES.mdrows/blocks S-C51 (done), S-C39 (done), S-C25 owed line. The counts paragraph is W-FINAL's; delta from this PR: S-C39part→done, S-C51blocked→done.Review round (
710867a9→ head): the roster version is bounded above as well as below (MAX_ROSTER_VERSION_STEP,400 error.album.roster_version_leap);apply_opconsults moderation standing; both write surfaces refuse an envelope attributed to another account; the SDK's roster publish goes through the generated operation; the advisory lock's shared keyspace, the storage a removed member keeps paying for (server: removing a member from an album's roster reclaims nothing — their uploaded bytes stay charged to an account that can no longer reach them #473) and the cursor break are written down. Decisions 26–34.Confirming-review round (
137fa2e5→ head): the author check is reversed on the ops surface and kept on the upload surface, argued from each surface's action set (decision 35 — continuations inherit their author; a member's delete of the owner's asset legitimately names the owner); the test that hid it is rebuilt and falsified (36); no roster counter can exceed what a generated client decodes (37); the window is clamped and its top-of-type boundary pinned (38). Filed core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475 for the core-side half. Decisions 35–38.Second confirming-review round (
3e4f5fcd→ head): following core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475 to its source reversed decision 35 again.capsule_core'ssign_lifecyclewas naming the wrong signer — breaking ordinary two-device use, not just sharing — so it is fixed in core (39), the ops author check is re-instated (40), and the record says so (41). Decisions 39–41.Validation
Worktree
/var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-server-album-membership-405, head710867a9. After the host deletedtarget/, every cargo/mise command ran withCARGO_TARGET_DIR=/var/tmp/capsule-lane-405/target(exceptgen-bindings, which hardcodestarget/).cargo nextest run -p capsule-core membership— 15 passed (9 new). PASScargo nextest run -p capsule-server -E 'test(membership) | test(expected_migrations)'— 20 passed. PASScargo nextest run -p capsule-server --test roster --test conformance— 19 passed (10 roster + walk). PASScargo nextest run -p capsule-server --test upload --test ops --test albums --test drops --test upgrade— 85 passed. PASScargo nextest run -p capsule-server --test blob --test conformance— 37 passed. PASScargo nextest run -p capsule-server --test sync --test conformance --lib -E '…index::|sync::'— 63 passed (with SDK album cases). PASScargo nextest run -p capsule-sdk -E 'test(albums)'— 13 passed. PASSmise run check-commits origin/feat/postgres-adapters-402— no errors in 10 commits. PASSmise run check-rustat7111c588(before the host deletedtarget/) — exit 0. PASSmise run check-rustat710867a9— killed at the 10-minute cap duringbuild-ruston a cold rebuild (exit 143):unclassified(host load), so its sub-tasks were re-run individually:format-check-rust,lint-check-rust,doc-check-rust,i18n-check,i18n-guard,openapi-check-kynos,architecture-check,license-check,translate-readme-checkcompleted inside the aggregate before the kill (the runner stops at the first failure) — PASS;mise run build-rustexit 0;mise run build-check-wasmexit 0;mise run build-ffiexit 0;mise run lint-check-ffiexit 0;mise run gen-bindingsexit 0 (tree clean afterwards);mise run verify-examplesexit 0. PASSmise run test-rustat710867a9— exit 0: workspace 1916 passed;capsule-core --features ffi738 passed;capsule-sdk --features ffi166 passed. PASSDOCKER_HOST=unix:///run/user/2000/podman/podman.sock CAPSULE_TEST_CONTAINER_USERNS=keep-id CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)'at710867a9(imagepostgres:18per [FEAT] Postgres adapters and a conformance suite for four durable ports #447's harness) — 9 passed, includingmembership::postgres::…::the_postgres_membership_store_conforms,index::postgres::…::the_postgres_index_conforms,postgres::…::the_migrations_apply_and_roll_back,boot::…::every_postgres_adapter_composes_from_the_boot_configuration. PASS (also 9/9 ate88a8b2a,c93f4886.)mise run check-docs-truth— cross-links 476, endpoint-census 101, module-paths 119, all resolve. PASSmise run check-md— 0 issues. PASSmise run check-docs— build Complete. PASScargo clippy --all-targetsoncapsule-core/capsule-serverreports pre-existing test-code lints in files this PR does not touch (src/counter/tests.rs,src/gc/tests.rs,tests/ops.rs:115,tests/sync.rs:295, corelocal_gallery_security); the repo'slint-check-rustdoes not run--all-targets.pre-existing.Review round (head
137fa2e5). Same worktree, sameCARGO_TARGET_DIR=/var/tmp/capsule-lane-405/target(exceptgen-bindings, run with it unset). Every command below was run in the foreground at this head unless a commit is named.cargo nextest run -p capsule-server -E 'test(membership) | test(roster)'— 37 passed. PASScargo nextest run -p capsule-server --test ops --test upload— 55 passed. PASScargo nextest run -p capsule-sdk -E 'test(albums)'— 15 passed (13 before this round). PASSmise run format-check-rust— exit 0. PASSmise run lint-check-rust— exit 0. PASSmise run build-rust— exit 0. PASSmise run doc-check-rust— exit 0. PASSmise run i18n-check— generated files up to date. PASSmise run i18n-guard— SwiftErrorCodematches the catalog (74 codes); no hardcoded literals. PASSmise run openapi-check-kynos— document up to date. PASSmise run architecture-check— boundaries intact. PASSmise run license-check— licenses ok. PASSmise run cli-surface-check—unavailable: no such task in this repository'smise.tomlat this head (mise ERROR no task cli-surface-check found; the task list has no*-surface-*task). Not substituted with anything weaker;cli-surface-checkwas named in the gate list handed to this lane, not in the repo.mise run build-check-wasm,mise run build-ffi,mise run lint-check-ffi,mise run verify-examples(16 passed),mise run translate-readme-check(12 translations up to date) — each exit 0. PASSmise run gen-bindings(withCARGO_TARGET_DIRunset) — exit 0, surface check passed, tree clean afterwards. PASSmise run test-rust— exit 0: workspace 1929 passed (1916 at710867a9, +13: 11 new server cases and 2 new SDK cases),capsule-core --features ffi738 passed,capsule-sdk --features ffi168 passed (166 before). PASScargo nextest list --workspace— 1929 listed, matching the run and the expected +13. PASSDOCKER_HOST=unix:///run/user/2000/podman/podman.sock CAPSULE_TEST_CONTAINER_USERNS=keep-id CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)'— first run 5 passed, 1 failed (membership::postgres::…::the_postgres_membership_store_conforms:18446744073709551615 is past what a BIGINT column holds), classifiedcausedby the new version-window conformance case:PostgresMembership::apply_rosternarrowed the counters toBIGINTbeforeprecheck, so the wedge the window refuses came back as a storage500where the in-memory adapter answered the typed refusal. Fixed at137fa2e5(conversions moved afterprecheck); re-run: 9/9 passed, membership row included. PASS after the fix.mise run check-docs-truth— cross-links, endpoint-census 101, module-paths 119, all resolve. PASSmise run check-md— 168 files, 0 issues. PASSmise run check-docs— 59 pages, build Complete. PASSmise run check-commits origin/feat/postgres-adapters-402— no errors in 17 commits. PASScausedcontainer failure above, fixed and re-run green.Confirming-review round (head
3e4f5fcd). Same worktree andCARGO_TARGET_DIR,gen-bindingswith it unset. Each gate its own foreground command.cargo nextest run -p capsule-server -E 'test(membership) | test(roster)'— 39 passed. PASScargo nextest run -p capsule-server --test ops --test upload— 55 passed. PASScargo nextest run -p capsule-sdk -E 'test(albums)'— 16 passed. PASScreated_by_usercheck intoapply_opand re-ran the two member-write cases: 2 failed, 0 passed (a_member_continues_the_owners_chain_under_the_owners_authorshipanda_writer_members_op_is_filed_under_the_owner_and_reaches_the_owners_feed); reverted, both pass. Evidence they now pass for the right reason and not because the helper fakes the fields. Working tree confirmed byte-identical afterwards (git statusclean).current_versionin the SDK mock's400body:Transport("response decode failed at invalid value: integer 18446744073709551615, expected i64 at line 1 column 298"), thecodeand the hint gone. Reverted; the shipped bound yields the typed refusal. Also verified directly that spargen emits nou64at all (grep -c "= u64;"over the generatedrest_client.rsis0, whileRosterResponse.roster_versioncarriesformat: uint64+minimum: 0and is stilli64).mise run openapi-kynos(regen decision 37 required) thenmise run openapi-check-kynos— document up to date. PASSmise run format-check-rust,lint-check-rust,build-rust,doc-check-rust,i18n-check,i18n-guard,architecture-check,license-check— each exit 0. PASSmise run build-check-wasm,build-ffi,lint-check-ffi,verify-examples,translate-readme-check— each exit 0. PASSmise run gen-bindings(CARGO_TARGET_DIRunset) — exit 0, tree clean afterwards. PASSmise run cli-surface-check—unavailable, unchanged on this base: no such task in the repository'smise.toml.mise run test-rust— exit 0: workspace 1932 passed (1929 last round, +3: ops −1 removed/+1 added, membership unit +3/−1, SDK +1),capsule-core --features ffi738 (unchanged),capsule-sdk --features ffi169 (+1). PASScargo nextest list --workspace— 1932, matching the run and the expected delta exactly. PASSDOCKER_HOST=unix:///run/user/2000/podman/podman.sock CAPSULE_TEST_CONTAINER_USERNS=keep-id CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)'— 9/9 passed, membership row included. PASSmise run check-docs-truth(module-paths 119, all resolve),check-md(0 issues),check-docs(build Complete) — PASSmise run check-commits origin/feat/postgres-adapters-402— no errors in 19 commits. PASSSecond confirming-review round (head
5fcd4a6c). Same worktree andCARGO_TARGET_DIR;gen-bindingswith it unset. Each gate its own foreground command.cargo nextest run -p capsule-core(full, since decision 39 touches core) — 735 passed (733 before; +2 new, 0 existing tests moved — none encoded the inherited-field assumption). PASScargo nextest run -p capsule-server --test ops --test upload --test roster— 68 passed. PASScargo nextest run -p capsule-server -E 'test(membership) | test(roster)'— 39 passed. PASS..base.clone()'s inheritance: both new core cases fail (a_continuation_from_a_second_device_names_it_and_verifies,a_members_continuation_verifies_under_the_members_own_directory); reverted, both pass.a_writer_members_op_...that failed instead. Both falsifications reverted, tree confirmed clean.mise run doc-check-rust— first run exit 101,redundant explicit link targetatprovenance.rs:33, classifiedcaused(a doc link added by decision 39). Fixed at5fcd4a6c; re-run exit 0. PASS after the fix.mise run format-check-rust,lint-check-rust,build-rust,i18n-check,i18n-guard,openapi-check-kynos,architecture-check,license-check,build-check-wasm,build-ffi,lint-check-ffi,verify-examples,translate-readme-check— each exit 0. PASSmise run gen-bindings(CARGO_TARGET_DIRunset) — exit 0, tree clean afterwards. PASSmise run cli-surface-check—unavailable, unchanged: no such task on this base.mise run test-rust— exit 0: workspace 1935 (1932 last round, +3: 2 core, 1 ops),capsule-core --features ffi740 (+2),capsule-sdk --features ffi169 (unchanged). PASScargo nextest list --workspace— 1935, matching the run and the expected delta exactly. PASSDOCKER_HOST=… CAPSULE_TEST_CONTAINER_USERNS=keep-id CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)'— 9/9 passed. PASSmise run check-docs-truth,check-md,check-docs— each exit 0. PASSmise run check-commits origin/feat/postgres-adapters-402— no errors in 22 commits. PASScausedand fixed: the doc link above. Everything else green at the final head.Risks and rollout
capsule-core(crypto::membership, one public path, see decision 23).WriteAuthority::album_write_accessandCursorCodec::{encode,decode}change signature; every implementor/call site in the workspace is updated. A cursor minted before this change fails asNotAuthentic(one full resync, the same event a key rotation is; cursor version byte unchanged, decision 20).CursorScope(the caller, and the album for an album page), so a cursor minted before this lands verifies against different input and is rejected as inauthentic. Clients recover unaided — a refused cursor is a re-sync from zero — but the observable consequence is a burst of full-feed reads on the first sync after the upgrade instead of the usual incremental pages. Nothing to migrate: cursors are opaque, stateless and server-minted. Written intodesign/import/download-sync.md's cursor-authenticity section; there is no deployment/upgrade note among the docs this PR touches (guides/self-hosting.mdexists but is outside this lane's manifest).MAX_ROSTER_VERSION_STEP(16) past the held version is refused with400 error.album.roster_version_leap. No roster in existence is affected — the bound only ever refuses a document the server would otherwise have latched on — but a client that increments its counter without publishing must re-sign atcurrent_version + 1, which the refusal names.POST /v1/albums/{album_id}/opsrefuses more than it did, in two ways: a suspended caller gets403 error.moderation.account_suspended, and an envelope whosecreated_by_useris not the authenticated caller gets400 error.upload.envelope_mismatch(decisions 27 and 40). A correct client already sends its own id on both write surfaces — a lifecycle record names the account that signed it, not the asset's creator.POST /v1/uploadanswers the same400for the same field. A client that attributes a write to another account stops working on either surface, deliberately.sign_lifecycleinheritedcreated_by_user/created_by_devicefrom the chain head while signing with the current device, so a delete, restore or metadata-update written by any device other than the one that created the asset named a device that did not sign it. Such a manifest failsverify_assetstep 8 for every reader and is refused here by invariant 7 — the two are the same defect seen from both sides, so this is not a regression the server introduces: those writes never verified. Single-device clients are unaffected. There is no server-side migration; a chain already carrying a record written that way keeps it, which is the residual left filed on core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475.i64, so an out-of-range extension member made the generated client fail to decode the problem and lose thecode(decision 37). The value rides the Englishdetailinstead;current_versionandmax_versionremain, bounded byMAX_ROSTER_VERSION. A client readingdeclaredoff the JSON — none exists in this repo — would need thedetail.membership/mod.rsandroutes/roster.rs; filed as server: removing a member from an album's roster reclaims nothing — their uploaded bytes stay charged to an account that can no longer reach them #473.serverefuses to boot untilcapsule-server-migration uphas applied it (existingassert_schema_current).MembershipStore,CursorScope,album_feed_page,BlobReadAccess; names are stable as pushed.Related Issues
Refs #405. Refs #462 (filed by this PR: shared bytes across owners on the blob route; member pending-upload answer). Refs #473 (filed by this PR: removing a member from a roster reclaims nothing — their uploaded bytes stay charged to an account that can no longer reach them). Refs #475 (filed by this PR:
sign_lifecycleinheritscreated_by_devicefrom the chain head while signing with the current device, so any continuation by another device failsverify_asset).Contributor Checklist
Decisions taken
Decisions taken inside the manifest during delivery (same shape):
error.*keys are added tolocales/en.jsononlyTaken: the six new keys go in
en.json; the source catalog already carries 133error.*keys against 90 in every other catalog, so the generator accepts source-only error codes and the twelve other catalogs fall back to the source locale.mise run i18nregenerates the per-platform files.Rejected: hand-writing twelve translations this lane cannot verify (the plan assumed thirteen translations; the tree shows that is not the rule for
error.*).Reverses: the plan's "13 translations" assumption.
amk_epochiscapsule_core::crypto::keys::AmkVersion, not a bareu32; the server port stores it asu64Taken: the roster carries the existing newtype so the epoch is the type the manifest's
amk_versionuses; the port and columns areu64/BIGINTlike every other counter.Rejected: a raw
u32(a second spelling of one concept in a frozen crate).Reverses: nothing.
PostgresMembership::apply_rosterserialises onpg_advisory_xact_lock(hashtext(album_id)), notSELECT … FOR UPDATETaken: a transaction-scoped advisory lock keyed on the album id, taken before the read; released by commit or rollback (the refusal arms return without writing).
Rejected:
FOR UPDATEon the roster row — the row does not exist for an album's first roster, so two first publishes would both upsert and the loser would silently overwrite the winner rather than answerStale.Reverses: the plan's "
SELECT … FOR UPDATEon the roster row".The server port speaks
capsule_core::crypto::membership::MemberRole; the column tokens are the server'sTaken:
membership::MemberRoleis a re-export of the core type;role_token/role_from_tokenin the server own thereader/writerstorage tokens.Rejected: a second server-side enum with a conversion.
Reverses: the plan's separate
pub enum MemberRolein the port.RosterOutcome::EpochRegressedcarriescurrent_version; the route renders it as the same409 error.album.roster_staleTaken:
prechecksetscurrent_versionfrom the held roster, so the route names the held version without a second read outside the critical section.Rejected: a second
current_rosterread (could name a later version than the one that refused; a second500path).Reverses: nothing.
The roster body must be canonical CBOR, and the size cap is applied to the encoded string first
Taken:
roster_cborlonger thanMAX_ROSTER_BYTES / 3 * 4 + 4is refused before decoding; decoded bytes that are not their own canonicalization are400 error.album.roster_malformed, because the store decides replays on bytes.Rejected: accepting non-canonical documents (a byte-different re-encoding of the same roster would be a
409, not a replay).Reverses: nothing.
A declared
owner_idonPOST /v1/uploadmay only agree with the album's ownerTaken: the authority answers the namespace;
resolve_owner(uploader, owner, declared)accepts absent or the album owner (the uploader's own id only when they are the owner); anything else — a member naming themselves included — is403 error.upload.owner_not_permitted.Rejected: accepting a member's own id (an asset under the member's namespace is one the owner's feed never carries).
Reverses: the plan's "declared
owner_id∈ {absent, uploader, album owner}" for the member case.Readers read; only writers write; finalization re-asks for the uploader; adoption stays owner-only
Taken:
MembershipAuthoritygrants either role;ProvisionedAuthorityadmits onlyMemberRole::Writer;finalize::revalidatekeys the re-check onrecord.upload_user_idand refuses anowner_idthat no longer matches; adoption requiresWriteRole::Owner.Rejected: keying finalization on
record.owner_id(would pass a member who was unshared mid-transfer).Reverses: the plan's call-site list, which missed
upload/finalize.rsandupload/mod.rs's re-export.OwnedAssetAuthorityandowned_assets()are removed, not kept besideMembershipAuthorityTaken: one production read authority;
membership_reads(members)is the wiring helper;BlobReferencegainsalbum_id.Rejected: keeping the owner-only authority as a second implementor with no call site.
Reverses: nothing.
Serialised behind [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 by sibling merge, and the base by merge commit
Taken:
origin/feat/postgres-adapters-402merged atb09fad7f(mergeccc0e351) and at its finald2a789d7(merge3f645bc7);fix/protocol-headers-every-route-404merged at7526395b(merge0a55362b) and at its final032b6af2(merge0e44b407);lib.rsresolved by keeping [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453's two-gate router and mountingpublish_album_rosterin theProtocolGategroup's library-writes mount;openapi.jsonregenerated withmise run openapi-kynosat each merge, never merged.Rejected: rebasing; merging the JSON.
Reverses: nothing.
An album's page is bound to
(owner, album)and the cursor MAC covers aCursorScopeTaken:
album_feed_page(owner, album, after, limit)/album_head_seq(owner, album)so the existing(owner_id, album_id)index serves them and a row another account filed under the same album id is excluded; MAC inputpayload || u32 BE len(caller) || caller || 0x00 | 0x01 || album; cursor version byte unchanged (an old cursor fails asNotAuthentic, the same one-time full resync a key rotation is); the position gaps on an album page disclose only how many changes the owner made elsewhere, accepted and documented.Rejected: a per-album numbering (a second sequence the client's anti-rewind mark would have to reconcile); bumping the version byte (would make old cursors
Malformedinstead ofNotAuthentic, a distinction the client does not act on).Reverses: the plan's
album_feed_page(album, after, limit)shape.GET /v1/sync?album_id=decides access before decoding the cursor, and answers one403for every refusalTaken: unprovisioned, never-a-member, removed (and a non-canonical id, which is simply unprovisioned) →
403 error.sync.album_access_denied; a foreign or malformed cursor on a page the caller may read is the existing400.Rejected:
404for the album, or a400for a non-canonical id (would distinguish an id space the write routes keep uniform).Reverses: nothing.
The SDK's
publish_rostermirrorsprovision's hand-written wire DTOs and surfacescurrent_versionTaken:
AlbumClient::publish_roster(&SignedAlbumRoster)sends base64 canonical CBOR onPUT {base}/{album_id}/roster;AlbumError::Statusgainscurrent_versionfrom the409body.Rejected: routing through the generated
rest::Client(the album client's transport is the session-backed oneprovisionalready uses; two transports for one surface).Reverses: nothing.
Freeze accounting for
capsule_core::crypto::membership(core: freeze the capsule-core public API and remove the dead surface #399)Taken: one public path,
capsule_core::crypto::membership::{MembershipError, MemberRole, RosterMember, AlbumRoster, SignedAlbumRoster}, no re-export at thecryptolevel; every item documented (the doc gate runs over private items). Call sites:capsule-server(routes/roster.rs,membership/mod.rsre-exportsMemberRole, tests),capsule-sdk(albums.rs, tests).SignedAlbumRoster::signis what a client and the SDK tests call;AlbumRoster::signing_bytesis whatsign/verifyand the server's tests use.Rejected: keeping the type in
capsule-server(clients could not sign it).Reverses: nothing.
Bytes shared across unrelated owners are filed, not fixed
Taken: server: blob fetch is decided from the first live reference, so bytes shared across owners can answer a member 404 or a wrong 403 #462 records that
find_referencedecides from the first live row, now a possible wrong403as well as the pre-existing wrong404, plus the member pending-upload answer; proposed fix is afind_referencesfold inserve::resolve.Rejected: changing the index port in this PR.
Reverses: nothing.
Files touched beyond the manifest's literal list, each forced by a manifest change
Taken:
capsule-server/src/upload/mod.rs(re-export ofWriteRole),capsule-server/src/upload/finalize.rs(the fourthalbum_write_accesscall site),capsule-server/src/album/tests.rsandcapsule-server/tests/albums.rs(constructions ofAlbumWriteAccess::Writable),capsule-server/tests/support/fault.rs(theAssetIndexdecorator),capsule-sdk/src/albums/tests.rs, and the generator outputs ofmise run i18n(capsule-i18n/src/bundles/en.json,capsule-android/…/strings.xml,capsule-swift/Generated/Localizable.xcstrings,capsule-web/src/i18n/messages/en.json). None is a new surface; each is the compile-time or generator consequence of a change the manifest names.Rejected: stopping the lane for a re-plan over mechanical consequences.
Reverses: the manifest's file list, by those entries.
Decisions taken on the orchestrator's review findings (
710867a9→ head), same shape:A roster version is bounded above as well as below (F1)
Taken:
MAX_ROSTER_VERSION_STEP = 16;precheckrefuses a version more than that above the held one and returnsRosterOutcome::VersionLeap { current_version, max_version }, reading a held version of0for an album with no roster so a first publish cannot latch the counter either. The route renders it400 error.album.roster_version_leapcarryingdeclared,current_versionandmax_version. Sixteen because the only legitimate gap is the number of membership changes a client made while it could not reach the server — a roster is a full document, so it publishes only its latest — and the refusal names the held version, so the repair is to re-sign the same statement atcurrent_version + 1, which says exactly the same thing (absence at a higher version is removal). Pinned by two conformance cases (both adapters), threeprecheckunit cases including the saturating edge at the top of the type, and two route cases that publish an absurd version and then assert the album still takes its next legitimate roster.Rejected:
409besideerror.album.roster_stale(a409on this surface means behind the server and re-read; this is a document the server would refuse whatever it held, which is the structural family the400carries — and the finding asked for a structural error); an owner reset verb (a second write surface for a state the window already makes unreachable); leaving the first roster unbounded (one publish atu64::MAXwedges an album whether or not it is the first).Reverses: the unbounded
roster_versionof decision 1 and itsAlbumRosterdoc comment, both of which said only "strictly monotonic".Note: only the owner may publish a roster (decision 2), so this was never a member's wedge; it was one publish — a compromised owner device, or a client with a broken counter — freezing an album's membership permanently with no recovery path. The bound removes the state, whoever could reach it.
apply_opconsults moderation standing (F2)Taken:
POST /v1/albums/{album_id}/opsasks the moderation port for the caller's standing on the same seamPOST /v1/uploaduses — before the write authority, the quota and anything stored — and renders the same403 error.moderation.account_suspended. A store that cannot answer refuses the write: an outage must not be a window in which every suspension is lifted. Two cases (a suspended owner, a suspended writer member) plus the fail-closed one.Rejected: a distinct code for a suspended member (the client's screen is the same); checking after the authority (a suspended caller would then learn whether the album is theirs).
Reverses: nothing; it closes a pre-existing gap the write widening made reachable by more accounts than the operator suspended knowingly.
created_by_usermust be the authenticated caller (F3)Taken: both write surfaces refuse the mismatch with
400 error.upload.envelope_mismatch— the status and code every other self-contradicting envelope field already takes (album_idagainst the path, the metadata hash against the bytes in hand).403on these surfaces is about capability; this is a request contradicting what the request itself establishes, and choosing the400keeps the two readings apart. Applied toPOST /v1/uploadas well as to the ops surface, because the manifest is stored verbatim and served back as provenance on both, so the field is equally believed on both. Both arms tested on both surfaces: the mismatch is refused (member→owner and owner→member) and the matching envelope still succeeds.Rejected:
403(would say "you may not write here" to a caller who may, and merge with the album refusal); aGateRejectvariant in the shared battery (GateContextis also built by the anonymous drop-link upload, which has no authenticated caller, so a required caller there would be a lie).Reverses: nothing; pre-existing, and named as such in this PR's own unresolved notes before the review.
A removed member's storage is recorded, not reclaimed (F6)
Taken: the behaviour is written where it is observable —
membership/mod.rsmodule docs androutes/roster.rs, the endpoint an operator would expect a reclaim to happen at — and filed as server: removing a member from an album's roster reclaims nothing — their uploaded bytes stay charged to an account that can no longer reach them #473. Truthfully: a writer member's upload is filed under the owner's namespace and charged to the uploader; removal changes neither, so the bytes stay charged to the removed member, who can no longer reach them (they may not write ops to that album). The only release path is the refcount collector'srelease_attribution(S-C44), which credits whoever the ledger holds the address against — so the removed member's quota is freed only if the owner deletes the asset.Rejected: wiring a reclaim on
apply_roster(no cheap seam exists that is not a protocol decision: who inherits the cost, whether removal is a deletion at all — two of the three answers let one account's quota pressure delete another account's photos); restating the finding's summary as given, which said the storage stays billed to the owner — the ledger bills the uploader, and the record has to be the code's behaviour, not the finding's paraphrase.Reverses: nothing.
The SDK's roster publish goes through the generated operation (F7)
Taken:
AlbumClient::publish_rosternow calls the generatedrest::Client::publish_album_roster— request body, success body and both problem shapes come from the committed contract — over a client built for the API root the album endpoint hangs off, carrying the transport's own credential (the session's token provider, or the fixed bearer). A401under a session is refreshed once and replayed, the waySyncConsumer::pulldoes it, because a per-request provider's pre-flight refresh cannot cover a token revoked mid-flight. Two cases added: the400leap refusal surfaces itscurrent_version, and the request's method, path and requiredX-Capsule-Protocolare asserted to be the document's.Rejected: recording an exception (there is no generator gap —
publish_album_rosteris plain JSON and spargen lowers it; the only gap in this area isapplication/cbor, which this operation deliberately does not use, decision 6); routingprovisionthrough the generated client too (a separate surface, a separate change, and no finding against it).Reverses: decision 22's "mirror
provision's hand-written wire DTOs", which is now wrong for the roster half.Accounting:
capsule-sdk/src/sync.rs(F8)Taken: recorded here, with what actually changed. The file appears in this PR's diff by the sibling merge of
fix/protocol-headers-every-route-404([FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453), at its commitsd7d6f532anda11d280a, not by any commit of this lane:build_clientnow takes itsreqwest::Clientfromcrate::net::http_client()(so the feed pull carries the protocol handshake, and the constructor gains a failure arm it did not have), andsync_feedtakes the protocol date as its first argument because the document declares the header required. There is nocapsule-sdk/src/sync/mod.rsin this tree; the module iscapsule-sdk/src/sync.rs, and no public constructor signature changed —SyncConsumer::{with_session, with_static_token}are as they were.Rejected: claiming it as this lane's change (it is not, and the accounting has to be checkable against
git log).Reverses: nothing; it fills a gap in decision 25's list.
The advisory lock's keyspace is shared, and that costs serialization only (F9)
Taken: said in a comment at the lock site and in
membership/postgres.rs's module docs:pg_advisory_xact_lock(hashtext($1))is a single-argument lock whose 32-bit space belongs to the whole database, so an unrelated advisory-lock user can collide with an album; the cost is two unrelated operations taking turns, never correctness, because equal album ids always hash equal and that is the only direction this lock is relied on for. The two-argument(classid, objid)repair is named for the day contention is measured.Rejected: switching to the two-argument form now (it needs a reserved class-id registry this tree does not have, and would be a schema-adjacent convention introduced for a cost nobody has measured).
Reverses: nothing; decision 12 chose the advisory lock and did not say what its key space costs.
The cursor break is operator-facing (Q5/Q8)
Taken: the sentence is in
design/import/download-sync.md's cursor-authenticity section and in## Risks and rolloutabove: every cursor issued before this deploy is refused as inauthentic, clients recover by re-syncing from zero, and the operator-visible consequence is one full-feed read per client on the first sync after the upgrade. Nothing to migrate — cursors are opaque, stateless and server-minted — and the version byte stays unchanged on purpose so an old cursor fails asNotAuthenticrather thanMalformed, which is the answer clients already act on.Rejected: adding it to
guides/self-hosting.md(the only deployment-shaped document in the tree, and outside this lane's manifest); bumping the cursor version byte to make the break explicit (decision 20 already rejected that, and it would change the failure a client sees without changing what it does).Reverses: nothing; decision 20 recorded the break for a reviewer and not for an operator.
Files touched beyond the manifest in this round, and the one behaviour fix the gates caught
Taken:
capsule-server/src/openapi/mod.rs— one row in the hand-maintained problem-extension table, so the400'sdeclared/current_version/max_versionare described by the contract rather than merely emitted underadditionalProperties: true; the document is the oracle, so a response shape it does not describe is drift. Everything else this round is inside the manifest or already accounted by decision 25 (capsule-sdk/src/albums/tests.rs, themise run i18noutputs). Separately,137fa2e5fixes a divergence the container suite caught:PostgresMembership::apply_rosternarrowed the counters toBIGINTbeforeprecheck, so the very version the new window refuses came back as a storage500where the in-memory adapter answered the typed refusal; the conversions now run after the port's own rule, and both adapters answer identically.Rejected: dropping the extensions to avoid touching the table (a client would then have to parse the ceiling out of an English sentence); stopping the lane for a re-plan over a one-row table entry the change mechanically requires (decision 25's precedent).
Reverses: the manifest's file list, by that entry.
Measured
SLICES.mdrow deltas (the counts paragraph is W-FINAL's and is untouched by this PR):S-C39part→done;S-C51blocked→done;S-C25staysdone*with its owed-line note rewritten. Net:done+2,part−1,blocked−1.Decisions taken on the confirming review of
137fa2e5(137fa2e5→ head), same shape:The author check comes off— REVERSED by decision 40. The check was correct; the client was not. Everything below about which actions each surface admits still holds and is what made 39 findable; what was wrong was the inference fromapply_opand stays oncreate_uploadsign_lifecycle's behaviour to what a continuation should name. Decision 28's original instinct was right — for the wrong stated reason (it argued from the field being believed, not fromverify_asset's step 6–8 coupling, which is the reason that survives).Taken: What each surface admits.
POST /v1/albums/{album_id}/opsadmits exactlydelete | trash-restore | metadata-update | derivative-add | derivative-replace(the allow-list incapsule-server/src/upload/envelope.rs:403-409), none of which may carry a nullprior_provenance_hash— every one is a chain continuation.POST /v1/uploadadmits exactlycreateandreplace(the dispatch atcapsule-server/src/routes/upload.rs:857-870), the two that move blob bytes and mint freshly authored ciphertext. The split is the codebase's own: "a write that moves blob bytes is an upload by definition".Why the field differs across it.
capsule_core's continuation builderWorkspace::sign_lifecycle(capsule-core/src/lifecycle/provenance.rs:26-46) fills every field it does not override from the chain head (..base.clone()), overriding onlyaction,prior_provenance_hash,retention_until,metadata_blob_hash,timestampandclient_version— the adjacent comment saysclient_versionis per-write "not the creator's", i.e. the code stating thatcreated_by_useris the creator's. So a continuation's author travels down the chain, and on a shared album a writer member's delete of the owner's asset names the owner. Every create path does the opposite and names the signer:lifecycle/import.rs:465,lifecycle/drops.rs:305,drop/mod.rs:500all writecreated_by_user: self.account.user_id— the last for an adopted web-upload drop, which design/web-upload.md:91 spells out ("setcreated_by_user/created_by_deviceto the adopter (the cryptographic author)").So the equality check is removed from
apply_op(capsule-server/src/routes/ops.rs:291-296, now a comment saying why) and kept oncreate_upload(capsule-server/src/routes/upload.rs:793-822), scoped by that surface's action set rather than by a per-action branch.How a continuation's caller is authenticated instead — written into the ops module docs at
capsule-server/src/routes/ops.rs:24-68rather than left to be inferred: (1) the bearer token; (2) standing (decision 27); (3) write capability —album_write_accessanswers owner-or-writer-member for this caller on this album, one indistinguishable403for reader, former member and stranger; (4) invariant 7 —created_by_devicemust be a device in the caller's own published directory (device_added_at(&caller, device),routes/ops.rs:322-338). That fourth is the real binding and is strictly stronger than comparing an account id: a device id in your directory is not something another account can borrow.And what the server cannot do, said plainly rather than papered over: it holds no keys and never parses
manifest_cbor, so it cannot checkdevice_sigat all and therefore cannot verify attribution — that the account a record names really signed it. That iscapsule_core::crypto::verify_asset's job on the client, which resolvescreated_by_deviceinsidecreated_by_user's directory and verifies the device signature under that entry (verify_asset.rs:130-156). The server's job is to refuse writes from accounts that may not write; deciding whether a stored record is authentic stays a key-holder's.Rejected: keeping the check and calling the refusal correct (it refuses exactly the writes
S-C51exists to admit); moving the comparison into the shared gate battery (GateContextis also built by the anonymous drop-link upload atroutes/drop.rs:1140, which has no authenticated caller, so a required caller there would be a lie); dropping the check fromcreate_uploadtoo (that surface's author is its signer in every builder that exists, and removing it would let a writer member file a new asset into the owner's album attributed to a third account).Reverses: the
apply_ophalf of decision 28. Thecreate_uploadhalf stands, now argued from the action set instead of from the field appearing on both surfaces.Filed: core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475 —
sign_lifecycleinheritscreated_by_devicefrom the chain head as well, while signing with the current device, so a continuation written by any device other than the creating one names a device that did not sign it.verify_assetrefuses it (step 8,BadDeviceSig) and invariant 7 refuses it here for its own reason. That is a core-side defect no server check can repair, and it means the writer-member lifecycle path has no client that can yet produce a manifest anything accepts — the server half is landed and documented, the client half is core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475. It bites the ordinary two-device single-account case too, not just sharing.The test that hid it, rebuilt — and falsified
Taken:
bobs_delete(capsule-server/tests/ops.rs:609-641) no longer overwritescreated_by_user; it asserts the envelope inherits the owner, and overwrites onlycreated_by_deviceto Bob's, because that is the one field invariant 7 resolves in the caller's directory. A named case,a_member_continues_the_owners_chain_under_the_owners_authorship(tests/ops.rs:822-874), is the case the finding describes: the owner creates, the writer member deletes, the write is accepted, it reaches the owner's feed at the answeredsync_seq, and the record still names the owner; its second arm swaps the device back to the owner's and asserts400 error.upload.device_not_authorized, pinning that the binding is the device and not the author.Falsified rather than asserted. Re-introducing the reverted check into
apply_opmakes both that case and the pre-existinga_writer_members_op_is_filed_under_the_owner_and_reaches_the_owners_feedfail (2 failed, 0 passed); with the check absent both pass. That is the evidence they now pass for the right reason — before the helper was rebuilt, the second of them passed with the check present, which is precisely how the defect survived review.Rejected: driving the envelope from a real
capsule_core::Workspaceinside a server test. Stated plainly because it is a deviation from the instruction's literal wording: this surface never parsesmanifest_cbor(its test manifests are deliberately not even valid CBOR —tests/ops.rs:88-93), so what a server test needs is the envelope projection, and standing up a Workspace, account, album keys and MLS authority to obtain one is the "do not wire up two incomplete complex systems" case AGENTS.md names. It would also be impossible today for the case that matters: per core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475, core cannot currently emit a member continuation that verifies. The helper instead applies core's rule, cites the function and line that owns it, and asserts the inherited value, so a change to that rule fails here.Reverses: the
bobs_deletehelper of decision 28's commit.Every counter the server can emit must be one a generated client can decode
Taken: Three changes, smallest first.
declaredstops being an extension member (capsule-server/src/routes/roster.rs:118-131) and rides the Englishdetail: it is the one number on that response the caller controls and the server therefore cannot bound.MAX_ROSTER_VERSION = i64::MAX(capsule-server/src/membership/mod.rs:100-118) clamps the acceptance window (precheck,mod.rs:296-305), so every counter the server can hold or name is inside both what aBIGINTstores and what a generated client reads — by construction, not by argument. And the extension table learnsformat(capsule-server/src/openapi/mod.rs:90-110), so the two remaining counters are described as theu64s they are;RosterStaleProblem.current_versionhad the same omission and is fixed in the same edit, as asked.The finding's mechanism was right and its remedy was not, and the difference matters. spargen lowers every integer in this contract as
i64and emits nou64anywhere —RosterResponse.roster_versioncarriesformat: uint64andminimum: 0and is still generated aspub type RosterResponserosterVersion = i64(verified in the generatedrest_client.rs;grep -c "= u64;"is 0). So typing the fields does not fix decoding, and the format alone would have left the bug in place while looking fixed. The defence has to be the bound on the value. That is written where the table is, so the next person adding an extension member reads it.Demonstrated both ways: with an out-of-range
current_versionthe SDK yieldsTransport("response decode failed at invalid value: integer 18446744073709551615, expected i64")— thecodeand the hint gone, indistinguishable from a network fault — and with the shipped bound it yields the typed refusal. Guarded at both ends:assert_decodablewalks the whole problem body incapsule-server/tests/roster.rs:88-106(so a future extension that forgets the rule fails there, not just the members this case names), andthe_widest_version_the_server_can_name_still_decodes(capsule-sdk/src/albums/tests.rs:449-492) pins the SDK ati64::MAX— the boundary, not a comfortable value in the middle.Rejected: adding an SDK case with
declaredabovei64::MAXas literally requested — the field is no longer on the wire, andRosterVersionLeapProblemisadditionalProperties: true, so an extra member is ignored by the generated decoder and such a test would pass vacuously. The property it was meant to pin is instead pinned at the widest value the server can emit, plus the whole-body walk on the server side.Reverses: decision 26's
declaredextension member.Residual: any integer extension in this document is still
i64to a generated client, so a non-Capsule server (or a proxy) sending an out-of-range one degrades toAlbumError::Transport. That is spargen's lowering, not this contract's, and is out of scope here.The boundary at the top of the counter
Taken:
a_held_version_at_the_top_of_the_type_is_unreachable_and_refuses_everything(capsule-server/src/membership/mod.rs:480-500) pinsheld = u64::MAXexactly: every publish is aVersionLeap— includingu64::MAXitself, which is not treated as a replay, because the ceiling is decided before the version comparison — while a version inside the storable range isStale. The clamp added in decision 37 makes that state unreachable rather than merely improbable: no roster aboveMAX_ROSTER_VERSIONis ever applied, so no held record can carry one. Two companion cases pin the property that made it worth fixing: nothing above the ceiling is ever accepted, and nomax_versionthe route can render is outsidei64, checked across held versions that cannot occur so the property does not depend on the ceiling being enforced elsewhere.Rejected: leaving
saturating_addwith a comment (atheld = u64::MAXit collapsesmax_versionontocurrent_version, which is the original wedge at the one value it could still occur — a bound that stops holding at its own boundary is worth one line to close).Reverses: decision 26's unclamped
saturating_addand itsheld = u64::MAX - 1test, which probed near the boundary rather than at it.Review finding 4 (the second citation in decision 33 said not to exist) was a scoping error on the reviewer's part, confirmed by the coordinator: "Risks and rollout" is a section of this pull-request body, not a file in
capsule-docs/. No change.Decisions taken on the second confirming review (
3e4f5fcd→ head), same shape:sign_lifecyclenames the record's own signer — the defect core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475 found, fixed at the sourceTaken:
capsule-core/src/lifecycle/provenance.rs:26-70re-mintscreated_by_userandcreated_by_deviceper write from the acting account and signing device, as all three create paths already do, instead of inheriting them frombasevia..base.clone(). Theclient_versiondistinction is kept intact — it was right, and it was the code stating the rule the two identity fields then contradicted. The field docs atcapsule-core/src/crypto/provenance/manifest.rs:129-150are corrected from "User who produced the asset" to what the verifier actually requires, citing the coupling and saying where the creator is recoverable (thecreaterecord at the head of the append-only chain).Why it must be the signer, from the chokepoint rather than from the field's name.
capsule-core/src/crypto/verify_asset.rs:130-156: step 6 rejects unlesscreated_by_deviceresolves insidecreated_by_user's directory; step 8 rejects unlessdevice_sigverifies under that entry's key. A record naming anyone but its own signer is unverifiable by every reader.design/web-upload.md:91says the same where it bothers to ("the adopter (the cryptographic author)"), and validation invariant 7 binds the device to the user's published directory. Album authority is untouched by this: it rideswrite_sigat step 10, so naming the acting member as author takes nothing from the owner.Blast radius was wider than the sharing case. Device B deleting an asset created on device A produced a manifest claiming A and signed by B — ordinary two-device single-account use, broken outright.
Pinned by the two cases that were broken:
a_continuation_from_a_second_device_names_it_and_verifiesanda_members_continuation_verifies_under_the_members_own_directory(provenance.rs:335-470), both assertingVerifyOutcome::Acceptand both asserting the creator survives in the chain's first record.Rejected: leaving it to the client half of
S-C51(it is not a sharing bug — the two-device case is broken today for every user); changingverify_assetto resolve against a writer's directory instead (that needs extra signed fields and is a schema change under a newprotocol_version, for no gain over naming the signer).Reverses: nothing in this PR; it corrects
capsule-corebehaviour that predates it. Manifest widened by the orchestrator tocapsule-core/src/lifecycle/provenance.rsand the two field docs incapsule-core/src/crypto/provenance/manifest.rs.Tests: zero existing tests moved. No test in the tree encoded the inherited-field assumption, which is exactly how the defect survived — the assumption was never asserted anywhere, in core or in the server suite. Core went 733 → 735.
The ops author check is re-instated
Taken: restored at
capsule-server/src/routes/ops.rs:288-303, beside invariant 7's device-directory binding rather than instead of it. With 39 in place a legitimate continuation always names its own signer, socreated_by_user != calleris a true structural refusal again and stops a member attributing a write to the owner or to a third account — on a surfaceS-C51opened to every writer member.400 error.upload.envelope_mismatch, the code every self-contradicting envelope field already uses.The ops module docs (
routes/ops.rs:24-64) no longer explain why the comparison was dropped; they state the settled rule — both fields name this record's signer, the server checks the id and the device's directory membership, and the signature itself is checked by the client because the server holds no keys. The action-set analysis from 35 is kept, because it is still true and is what made 39 findable; only the conclusion drawn from it changed.Rejected: keeping the check off and relying on invariant 7's device half alone (it binds the device, not the claimed account, so a member could still name another account while presenting their own device); a
403(capability, not contradiction).Reverses: decision 35, and restores decision 28's
apply_ophalf.The record says what happened, including that I was wrong twice
Taken: decision 35 is marked reversed in place with the one-sentence reason (the check was correct; the client was not), and decision 28 is noted as right for the wrong stated reason — it argued from the field being believed rather than from
verify_asset's step 6–8 coupling, which is the argument that survives.## Risks and rolloutsays "a correct client already sends its own id" again, now of both surfaces, and gains the deploy note: clients older than 39 cannot produce an acceptable continuation from a second device at all — though that is not a regression this PR introduces, because such manifests never verified for any reader. core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475 is updated by comment: the core half is fixed here; what remains filed is chains already written with an inherited device id, for which there is no migration.Rejected: quietly editing 35 as though it had always said this (the record is for a human reconstructing why the code looks like it does, and two reversals in three rounds is the useful part).
Reverses: nothing.
Falsification for 40, the outcome that distinguishes this from the state the review found. With the check present (as shipped), all three member-write cases pass:
a_writer_members_op_is_filed_under_the_owner_and_reaches_the_owners_feed,a_member_continues_the_owners_chain_under_their_own_authorship,an_op_attributed_to_another_account_is_refused— 3 passed. With the check removed (decision 35's state), the attribution refusal fails — 2 passed, 1 failed. And in the state the review found (check present, core unfixed), the first of those failed, which is the defect. For 39, restoring the..base.clone()inheritance fails both new core cases; with the fix, both pass. Every falsification was reverted and the tree confirmed clean afterwards.Unresolved review notes
— resolved, decision 27.apply_opnever checks moderationstanding()whilecreate_uploaddoesThe server never checks— resolved on both write surfaces, decisions 28, 39 and 40. It took three passes: the check went on, came off when it appeared to refuse legitimate member writes, and went back on once the real cause — the client naming the wrong signer — was fixed inenvelope.created_by_user == callercapsule-core.The server cannot verify attribution at all, on any surface: it holds no keys and never parses the manifest, so
device_sigis unchecked here by construction.verify_assetis where that is decided, on the client. Not a gap this PR can close; stated so nobody reads invariant 7 as more than it is.— fixed here, decision 39. What remains on core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475: chains that already contain a record written with an inherited device id stay unverifiable at that record, and there is no migration in this PR. Nothing reads those chains server-side (the server never parses a manifest), so it is a client-side repair whenever someone specifies one.Workspace::sign_lifecycleinheritscreated_by_devicefrom the chain headEvery integer extension in the contract is lowered by spargen as
i64, so an out-of-range value from any server degrades the SDK to a transport error. Capsule's own counters are bounded belowi64::MAX(decision 37); a foreign server's are not.postgres/error.rsandpostgres/testing.rsmodule docs still say "four" adapters (outside this lane's manifest).A writer member removed from a roster keeps the storage they wrote charged to their own quota, reachable only by the album owner, and nothing on the removal path reclaims it — filed as server: removing a member from an album's roster reclaims nothing — their uploaded bytes stay charged to an account that can no longer reach them #473, decision 29.
Bytes shared across unrelated owners are still decided from the first live reference on the blob route — server: blob fetch is decided from the first live reference, so bytes shared across owners can answer a member 404 or a wrong 403 #462, decision 24. Not implemented here by instruction.
capsule-sdk::albums::provisionstill hand-writes its request/response DTOs. The roster half now goes through the generated client (decision 30);POST /v1/albumswas not in scope for this round and has no finding against it.mise run cli-surface-check, named in this lane's gate list, does not exist as a task in this repository — reportedunavailablerather than substituted.Confirming review, 2026-09-09 (appended by the orchestrator). The round-3 review of
3e4f5fcd..5fcd4a6cfound no correctness defect and reproduced the falsification table in a disposable copy. It did establish that core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475's residual is wider than one record:verify_assetstep 11 requiresprior_provenance_hash == local_chain_head, andsync_applyquarantines without advancing local state, so a record written by the oldsign_lifecyclefails at step 8 and every later record in that asset's chain then fails at step 11 — the asset stalls permanently at the point of first inheritance rather than flagging one record.BadDeviceSig/ForgedChainare also what a genuine forgery produces, so such a chain is not distinguishable from one under attack by its verdict alone. Recorded on core: sign_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset #475; the client-side repair it owes is unchanged in kind but larger in blast radius.