Skip to content

[FEAT] Server-side album membership (S-C51) - #458

Open
justin13888 wants to merge 28 commits into
feat/postgres-adapters-402from
feat/server-album-membership-405
Open

[FEAT] Server-side album membership (S-C51)#458
justin13888 wants to merge 28 commits into
feat/postgres-adapters-402from
feat/server-album-membership-405

Conversation

@justin13888

@justin13888 justin13888 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Description

Server-side album membership (S-C51, closing S-C39's 403): the owner-signed roster attestation in capsule-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's 403 for a former member, GET /v1/sync?album_id= for members, and AlbumClient::publish_roster in the SDK. Base is #447 (feat/postgres-adapters-402); #453 is merged in by sibling merge.

Summary

  • capsule_core::crypto::membershipAlbumRoster / SignedAlbumRoster::{sign, verify}; canonical-CBOR signing bytes; a revoked device may not attest.
  • capsule_server::membershipMembershipStore::{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 the ProtocolGate group; four error.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 (replaces OwnedAssetAuthority); BlobReadAccess::Revoked403 error.blob.access_revoked; never-members still get the byte-identical 404.
  • AssetIndex::{album_feed_page, album_head_seq} bound to (owner, album); CursorScope in 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.md row; SLICES.md rows/blocks S-C51 (done), S-C39 (done), S-C25 owed line. The counts paragraph is W-FINAL's; delta from this PR: S-C39 partdone, S-C51 blockeddone.

Validation

Worktree /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-server-album-membership-405, head 710867a9. After the host deleted target/, every cargo/mise command ran with CARGO_TARGET_DIR=/var/tmp/capsule-lane-405/target (except gen-bindings, which hardcodes target/).

  • cargo nextest run -p capsule-core membership — 15 passed (9 new). PASS
  • cargo nextest run -p capsule-server -E 'test(membership) | test(expected_migrations)' — 20 passed. PASS
  • cargo nextest run -p capsule-server --test roster --test conformance — 19 passed (10 roster + walk). PASS
  • cargo nextest run -p capsule-server --test upload --test ops --test albums --test drops --test upgrade — 85 passed. PASS
  • cargo nextest run -p capsule-server --test blob --test conformance — 37 passed. PASS
  • cargo nextest run -p capsule-server --test sync --test conformance --lib -E '…index::|sync::' — 63 passed (with SDK album cases). PASS
  • cargo nextest run -p capsule-sdk -E 'test(albums)' — 13 passed. PASS
  • mise run check-commits origin/feat/postgres-adapters-402 — no errors in 10 commits. PASS
  • mise run check-rust at 7111c588 (before the host deleted target/) — exit 0. PASS
  • mise run check-rust at 710867a9 — killed at the 10-minute cap during build-rust on 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-check completed inside the aggregate before the kill (the runner stops at the first failure) — PASS; mise run build-rust exit 0; mise run build-check-wasm exit 0; mise run build-ffi exit 0; mise run lint-check-ffi exit 0; mise run gen-bindings exit 0 (tree clean afterwards); mise run verify-examples exit 0. PASS
  • mise run test-rust at 710867a9 — exit 0: workspace 1916 passed; capsule-core --features ffi 738 passed; capsule-sdk --features ffi 166 passed. PASS
  • DOCKER_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)' at 710867a9 (image postgres:18 per [FEAT] Postgres adapters and a conformance suite for four durable ports #447's harness) — 9 passed, including membership::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 at e88a8b2a, c93f4886.)
  • mise run check-docs-truth — cross-links 476, endpoint-census 101, module-paths 119, all resolve. PASS
  • mise run check-md — 0 issues. PASS
  • mise run check-docs — build Complete. PASS
  • cargo clippy --all-targets on capsule-core/capsule-server reports 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, core local_gallery_security); the repo's lint-check-rust does not run --all-targets. pre-existing.

Risks and rollout

Related Issues

Refs #405. Refs #462 (filed by this PR: shared bytes across owners on the blob route; member pending-upload answer).

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

Decisions taken

Issue 405 - server: server-side album membership (S-C51), which the blob 403 and album writes wait on
Plan:     v1 (this document)
Branch:   feat/server-album-membership-405
Base:     feat/postgres-adapters-402 (head of the W-PG PR #447), stacked; the PR targets that branch
Cause:    -
Touches:  capsule-core/src/crypto/{membership.rs,mod.rs}; capsule-server/src/membership/**; capsule-server/migration/src/{lib.rs,m20260902_000005_album_membership.rs}; capsule-server/src/{lib,app,boot}.rs; capsule-server/src/postgres/mod.rs; capsule-server/src/upload/authority.rs; capsule-server/src/album/authority.rs; capsule-server/src/serve/{mod,authority}.rs; capsule-server/src/index/{mod,memory,postgres,conformance}.rs; capsule-server/src/sync/cursor.rs; capsule-server/src/routes/{roster,blob,ops,upload,drop,sync,mod}.rs; capsule-server/openapi.json (regen); capsule-server/tests/{support/mod,roster,blob,ops,upload,sync,conformance}.rs; locales/*.json; capsule-i18n/src/generated.rs (regen); capsule-sdk/src/{sync,albums}.rs; capsule-docs design/{authorization,import/download-sync,threat-model/validation,api-surfaces}.md; SLICES.md
Will not: federation capability path or JWT/.well-known revocation (#406); Postgres adapters for AlbumStore/DeviceDirectoryStore; wiring the durable boot arm past Valkey (#403); non-owner admins; per-device roster entries; a roster GET; a CLI verb; any change to the ops closed action set or protocol_version; server-held MLS commits; re-verifying stored rosters on read; fixing find_reference's first-live-reference choice for bytes shared across unrelated owners (filed)
Lane:     serialised behind #402 and #404 (openapi.json); forecast collisions: #453 on openapi.json, lib.rs, tests/support/mod.rs, tests/conformance.rs, boot.rs; #403 on boot.rs, tests/support/mod.rs; #407 on openapi.json, lib.rs, locales, app.rs; #406 stacks on membership/**, serve/authority.rs, routes/{sync,blob}.rs, sync/cursor.rs
Settled:  the 403 is for a caller who once had access and every other refusal is byte-identical to an unknown address (serve/authority.rs:26-31; download-sync.md:50; SLICES.md:2650-2657); the authority is consulted before every 410 (serve/mod.rs:191-209); invariants 6/7 answer 403 and the write path already refuses uniformly for unprovisioned and not-yours (validation.md:26-27; album/authority.rs:104-108); an admin-signed album artefact is verified against the owner's published directory before it is stored and not re-verified on read (album/mod.rs:73-76; routes/upgrade.rs:335-361; crypto/upgrade.rs:117-146); a revoked directory entry may not sign new things (album/authority.rs:99-101); the server cannot read the MLS roster (mls.md ChaCha20 paragraph; download-sync.md:52; SLICES.md:3883-3890); member removal is an MLS Remove plus an AMK epoch bump and unsharing cuts read access to historical photos at the transport level (keys.md:217-218; mls.md "Remove user Charlie"; federation.md:77,117); owner_id is the namespace and upload_user_id is billed (server.md:122; routes/upload.rs:1028,1052-1053); a new write surface must appear in the idempotency table (validation.md:172); adapters live beside their port, one container test per port under a module named postgres_conformance, migrations compiled into EXPECTED_MIGRATIONS (postgres/mod.rs:6-12,39-44; postgres/testing.rs:1-27; .config/nextest.toml); openapi.json is regenerated, never edited, and is the docs' endpoint oracle (mise.toml:234-240; check-endpoint-census.mjs:1-18); spargen cannot lower application/cbor (capsule-sdk/build.rs:62-80).

Decisions taken.

1. The membership fact is a full-roster attestation signed by a device in the album owner's published device directory (option (b), read as "device-directory cross-signed admin device signs the roster")
   Taken:    capsule-core::crypto::membership::{AlbumRoster, SignedAlbumRoster}; canonical-CBOR signing bytes; verify(&DeviceDirectory) with the revoked-device refusal; the server anchors on AlbumRecord.owner_id's directory exactly as routes/upgrade.rs:335-361 does. Full document with strictly monotonic roster_version and non-decreasing amk_epoch; removal is absence at a higher version; the owner is implicit.
   Rejected: (a) deriving from the MLS group commit — control messages are AEAD-protected and the server holds no group key (mls.md; download-sync.md:52); core's ServerChainView is a commit-hash classification, not a roster (resilience.rs:66-91); persisting commits would make the server the MLS delivery service, which no doc assigns to this lane. (b-naive) listing memberships inside the device directory — the directory is per-user, master-signed and served to any authenticated caller (fetch_device_directory in openapi.json), so memberships would become public metadata; the member's IK is the wrong signer (keys.md:102 puts membership under the admin key); every change would bump directory_version and re-pin every peer (keys.md:163). (c) an ops-endpoint add_member action — the closed set is asset-lifecycle and adding a value needs a later protocol_version that pins old albums out (authorization.md closed-set paragraph); a roster has no file_id, chain head or write_sig the server could check, so it would be verified by device_sig anyway, i.e. (b) with worse ergonomics. Delta grants — need per-grant ids plus a separate revocation artefact and appear nowhere in the idempotency table; the directory's monotonic-document shape is the tree's precedent (validation.md:73).
   Reverses: nothing.

2. Trust anchor is the owner account's directory; only the owner account may publish a roster
   Taken:    caller must equal AlbumRecord.owner_id and attested_by_user; a member (even an MLS admin) gets 404 error.album.roster_not_found, the album ceremonies' "not yours is not found" rule (album/mod.rs begin_upgrade).
   Rejected: registering an album admin public key at provisioning — POST /v1/albums refuses any body field beyond the id (album/mod.rs:27-31) and it would be a second anchor beside S-C42's identity anchor.
   Reverses: nothing; records the S-C25 "Owed" line (SLICES.md:2193) as landed here rather than by S-C4/S-C5.

3. Port shape and placement
   Taken:    capsule-server/src/membership/{mod,memory,conformance,postgres}.rs with MembershipStore::{apply_roster (one critical section), membership, current_roster}; tables album_rosters and album_members with revoked_at_version/revoked_epoch retained rather than deleted, so "once had access" is a stored fact; migration m20260902_000005_album_membership; EXPECTED_MIGRATIONS += 1; boot.rs compose test gains PostgresMembership.
   Rejected: nesting under album/ (the quota and store ports set the top-level-module-per-port shape, postgres/mod.rs:6-12); deleting revoked rows (would make the 403 unrenderable, serve/authority.rs:26-31); wiring PostgresMembership into assemble (the durable arm stops at durable_ports_owed, boot.rs:249, and is #403's).
   Reverses: nothing.

4. Blob route: 403 only for a stored prior membership; 404 for everyone else; authority still first
   Taken:    BlobReference gains album_id; BlobReadAccess::Revoked → ServeResolution::Forbidden → 403 error.blob.access_revoked; Never → Unrelated → 404 unchanged; a former member gets 403 regardless of hold/tombstone state.
   Rejected: 403 for any non-owner (existence oracle, SLICES.md:2650-2657); indistinguishable 404 for former members (download-sync.md:48 requires the authorization-change signal; share-links.md:32's rule is for capability URLs); role-scoped reads (read-derivative-only is the federation capability's, federation.md:100).
   Reverses: the "no variant yet" row in serve/authority.rs:16-24 and the download-sync.md:50-52 status paragraphs, both rewritten.

5. Write widening files under the album owner and bills the uploader
   Taken:    WriteAuthority::album_write_access(caller, album) → Writable{owner_id, role, pin, quiescing}; upload/ops/drop use the authority's owner_id; resolve_owner accepts absent, uploader or the album owner; reader/revoked/never → Denied → existing 403 error.upload.album_access_denied; invariant 7 stays on the caller's own directory.
   Rejected: filing a member's asset under the member (the album owner's feed would never carry it, index feed_page is per owner, index/postgres.rs:1032-1034); a distinct 403 code for revoked writers (the client's action is the same re-sync, and invariant 6 already says 403 uniformly).
   Reverses: routes/upload.rs:186-190 and :1431-1440's "no relationship port" refusal, now answered by the membership port.

6. Roster transport is JSON with base64 canonical CBOR on PUT /v1/albums/{album_id}/roster
   Taken:    RosterRequest{roster_cbor}; 200/400/403/404/409/500 with error.album.roster_{malformed,attester,not_found,stale} and error.album.unavailable; 512 KiB cap; documented as invariant 33 and an idempotency row keyed (album_id, roster_version).
   Rejected: an application/cbor body like the upgrade route (spargen omits it, capsule-sdk/build.rs:62-80, so the SDK would need a hand-written client); a GET of the roster (the client's membership authority is MLS; the server copy is a transport control, federation.md:117).
   Reverses: nothing.

7. Sync membership path is album-scoped over the owner's sequence with a caller-and-album-bound cursor
   Taken:    GET /v1/sync?album_id=; AssetIndex::{album_feed_page, album_head_seq}; CursorCodec takes CursorScope{caller, album: Option}; unknown/not-member/revoked all answer 403 error.sync.album_access_denied.
   Rejected: filtering the owner's page in the route (breaks limit/has_more); 404 for the album (the write routes already answer a uniform 403 for the same id space, and the album id is client-derived and unguessable, album/authority.rs:104-108); leaving the sync path to W-FED (S-C51's done-when needs a member to learn addresses, and #406 says it depends on this).
   Reverses: routes/sync.rs:218-222's comment.

8. Epoch and revocation representation
   Taken:    the roster carries amk_epoch; the store records granted_epoch and revoked{at_version, at_epoch}; the server checks only monotonicity of roster_version (strict) and amk_epoch (non-decreasing); the index's invariant 18 remains the manifest-side guard.
   Rejected: serving a former member the blobs of epochs they still hold keys for (federation.md:77 cuts read access to historical photos on unshare; S-C51 done-when says 403); tying roster epochs to the index's high-water mark (a roster legitimately precedes the first manifest of its epoch).
   Reverses: nothing.

9. Manifest correction and docs
   Taken:    capsule-core gains one additive module under the freeze (one public path, no duplicates); docs land with the route in one PR because check-endpoint-census resolves against openapi.json; SLICES.md rows S-C51 and S-C39 flip with their blocks and the counts paragraph.
   Rejected: keeping the signed type in capsule-server (clients could not sign it without depending on the server crate); a planned-modules.txt entry (the module ships).
   Reverses: the orchestrator's manifest forecast, which omitted capsule-core, upload/authority.rs, routes/{upload,drop,sync}.rs, sync/cursor.rs, app.rs, boot.rs, lib.rs, migration/, tests, locales, the SDK, and three design docs.

Decisions taken inside the manifest during delivery (same shape):

  1. error.* keys are added to locales/en.json only
    Taken: the six new keys go in en.json; the source catalog already carries 133 error.* 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 i18n regenerates 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.

  2. amk_epoch is capsule_core::crypto::keys::AmkVersion, not a bare u32; the server port stores it as u64
    Taken: the roster carries the existing newtype so the epoch is the type the manifest's amk_version uses; the port and columns are u64/BIGINT like every other counter.
    Rejected: a raw u32 (a second spelling of one concept in a frozen crate).
    Reverses: nothing.

  3. PostgresMembership::apply_roster serialises on pg_advisory_xact_lock(hashtext(album_id)), not SELECT … FOR UPDATE
    Taken: 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 UPDATE on 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 answer Stale.
    Reverses: the plan's "SELECT … FOR UPDATE on the roster row".

  4. The server port speaks capsule_core::crypto::membership::MemberRole; the column tokens are the server's
    Taken: membership::MemberRole is a re-export of the core type; role_token/role_from_token in the server own the reader/writer storage tokens.
    Rejected: a second server-side enum with a conversion.
    Reverses: the plan's separate pub enum MemberRole in the port.

  5. RosterOutcome::EpochRegressed carries current_version; the route renders it as the same 409 error.album.roster_stale
    Taken: precheck sets current_version from the held roster, so the route names the held version without a second read outside the critical section.
    Rejected: a second current_roster read (could name a later version than the one that refused; a second 500 path).
    Reverses: nothing.

  6. The roster body must be canonical CBOR, and the size cap is applied to the encoded string first
    Taken: roster_cbor longer than MAX_ROSTER_BYTES / 3 * 4 + 4 is refused before decoding; decoded bytes that are not their own canonicalization are 400 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.

  7. A declared owner_id on POST /v1/upload may only agree with the album's owner
    Taken: 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 — is 403 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.

  8. Readers read; only writers write; finalization re-asks for the uploader; adoption stays owner-only
    Taken: MembershipAuthority grants either role; ProvisionedAuthority admits only MemberRole::Writer; finalize::revalidate keys the re-check on record.upload_user_id and refuses an owner_id that no longer matches; adoption requires WriteRole::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.rs and upload/mod.rs's re-export.

  9. OwnedAssetAuthority and owned_assets() are removed, not kept beside MembershipAuthority
    Taken: one production read authority; membership_reads(members) is the wiring helper; BlobReference gains album_id.
    Rejected: keeping the owner-only authority as a second implementor with no call site.
    Reverses: nothing.

  10. 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-402 merged at b09fad7f (merge ccc0e351) and at its final d2a789d7 (merge 3f645bc7); fix/protocol-headers-every-route-404 merged at 7526395b (merge 0a55362b) and at its final 032b6af2 (merge 0e44b407); lib.rs resolved by keeping [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453's two-gate router and mounting publish_album_roster in the ProtocolGate group's library-writes mount; openapi.json regenerated with mise run openapi-kynos at each merge, never merged.
    Rejected: rebasing; merging the JSON.
    Reverses: nothing.

  11. An album's page is bound to (owner, album) and the cursor MAC covers a CursorScope
    Taken: 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 input payload || u32 BE len(caller) || caller || 0x00 | 0x01 || album; cursor version byte unchanged (an old cursor fails as NotAuthentic, 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 Malformed instead of NotAuthentic, a distinction the client does not act on).
    Reverses: the plan's album_feed_page(album, after, limit) shape.

  12. GET /v1/sync?album_id= decides access before decoding the cursor, and answers one 403 for every refusal
    Taken: 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 existing 400.
    Rejected: 404 for the album, or a 400 for a non-canonical id (would distinguish an id space the write routes keep uniform).
    Reverses: nothing.

  13. The SDK's publish_roster mirrors provision's hand-written wire DTOs and surfaces current_version
    Taken: AlbumClient::publish_roster(&SignedAlbumRoster) sends base64 canonical CBOR on PUT {base}/{album_id}/roster; AlbumError::Status gains current_version from the 409 body.
    Rejected: routing through the generated rest::Client (the album client's transport is the session-backed one provision already uses; two transports for one surface).
    Reverses: nothing.

  14. 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 the crypto level; every item documented (the doc gate runs over private items). Call sites: capsule-server (routes/roster.rs, membership/mod.rs re-exports MemberRole, tests), capsule-sdk (albums.rs, tests). SignedAlbumRoster::sign is what a client and the SDK tests call; AlbumRoster::signing_bytes is what sign/verify and the server's tests use.
    Rejected: keeping the type in capsule-server (clients could not sign it).
    Reverses: nothing.

  15. 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_reference decides from the first live row, now a possible wrong 403 as well as the pre-existing wrong 404, plus the member pending-upload answer; proposed fix is a find_references fold in serve::resolve.
    Rejected: changing the index port in this PR.
    Reverses: nothing.

  16. Files touched beyond the manifest's literal list, each forced by a manifest change
    Taken: capsule-server/src/upload/mod.rs (re-export of WriteRole), capsule-server/src/upload/finalize.rs (the fourth album_write_access call site), capsule-server/src/album/tests.rs and capsule-server/tests/albums.rs (constructions of AlbumWriteAccess::Writable), capsule-server/tests/support/fault.rs (the AssetIndex decorator), capsule-sdk/src/albums/tests.rs, and the generator outputs of mise 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.

Unresolved review notes

  • apply_op never checks moderation standing() while create_upload does (pre-existing, noted by the slice-4 read).
  • The server never checks envelope.created_by_user == caller (pre-existing; invariant 7 binds the device to the caller's own directory).
  • postgres/error.rs and postgres/testing.rs module docs still say "four" adapters (outside this lane's manifest).

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

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

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 710867a
Status: ✅  Deploy successful!
Preview URL: https://703fc03e.capsule-22k.pages.dev
Branch Preview URL: https://feat-server-album-membership.capsule-22k.pages.dev

View logs

…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.
… 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.
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant