Skip to content

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

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

justin13888 wants to merge 40 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.

  • 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_op consults 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's sign_lifecycle was 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, 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.

Review round (head 137fa2e5). Same worktree, same CARGO_TARGET_DIR=/var/tmp/capsule-lane-405/target (except gen-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. PASS
  • cargo nextest run -p capsule-server --test ops --test upload — 55 passed. PASS
  • cargo nextest run -p capsule-sdk -E 'test(albums)' — 15 passed (13 before this round). PASS
  • mise run format-check-rust — exit 0. PASS
  • mise run lint-check-rust — exit 0. PASS
  • mise run build-rust — exit 0. PASS
  • mise run doc-check-rust — exit 0. PASS
  • mise run i18n-check — generated files up to date. PASS
  • mise run i18n-guard — Swift ErrorCode matches the catalog (74 codes); no hardcoded literals. PASS
  • mise run openapi-check-kynos — document up to date. PASS
  • mise run architecture-check — boundaries intact. PASS
  • mise run license-check — licenses ok. PASS
  • mise run cli-surface-checkunavailable: no such task in this repository's mise.toml at this head (mise ERROR no task cli-surface-check found; the task list has no *-surface-* task). Not substituted with anything weaker; cli-surface-check was 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. PASS
  • mise run gen-bindings (with CARGO_TARGET_DIR unset) — exit 0, surface check passed, tree clean afterwards. PASS
  • mise run test-rust — exit 0: workspace 1929 passed (1916 at 710867a9, +13: 11 new server cases and 2 new SDK cases), capsule-core --features ffi 738 passed, capsule-sdk --features ffi 168 passed (166 before). PASS
  • cargo nextest list --workspace — 1929 listed, matching the run and the expected +13. 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)'first run 5 passed, 1 failed (membership::postgres::…::the_postgres_membership_store_conforms: 18446744073709551615 is past what a BIGINT column holds), classified caused by the new version-window conformance case: PostgresMembership::apply_roster narrowed the counters to BIGINT before precheck, so the wedge the window refuses came back as a storage 500 where the in-memory adapter answered the typed refusal. Fixed at 137fa2e5 (conversions moved after precheck); 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. PASS
  • mise run check-md — 168 files, 0 issues. PASS
  • mise run check-docs — 59 pages, build Complete. PASS
  • mise run check-commits origin/feat/postgres-adapters-402 — no errors in 17 commits. PASS
  • No gate failed at the final head. The one failure observed this round is the caused container failure above, fixed and re-run green.

Confirming-review round (head 3e4f5fcd). Same worktree and CARGO_TARGET_DIR, gen-bindings with it unset. Each gate its own foreground command.

  • cargo nextest run -p capsule-server -E 'test(membership) | test(roster)' — 39 passed. PASS
  • cargo nextest run -p capsule-server --test ops --test upload — 55 passed. PASS
  • cargo nextest run -p capsule-sdk -E 'test(albums)' — 16 passed. PASS
  • Falsification of decision 36 — re-introduced the reverted created_by_user check into apply_op and re-ran the two member-write cases: 2 failed, 0 passed (a_member_continues_the_owners_chain_under_the_owners_authorship and a_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 status clean).
  • Falsification of decision 37 — put an out-of-range current_version in the SDK mock's 400 body: Transport("response decode failed at invalid value: integer 18446744073709551615, expected i64 at line 1 column 298"), the code and the hint gone. Reverted; the shipped bound yields the typed refusal. Also verified directly that spargen emits no u64 at all (grep -c "= u64;" over the generated rest_client.rs is 0, while RosterResponse.roster_version carries format: uint64 + minimum: 0 and is still i64).
  • mise run openapi-kynos (regen decision 37 required) then mise run openapi-check-kynos — document up to date. PASS
  • mise run format-check-rust, lint-check-rust, build-rust, doc-check-rust, i18n-check, i18n-guard, architecture-check, license-check — each exit 0. PASS
  • mise run build-check-wasm, build-ffi, lint-check-ffi, verify-examples, translate-readme-check — each exit 0. PASS
  • mise run gen-bindings (CARGO_TARGET_DIR unset) — exit 0, tree clean afterwards. PASS
  • mise run cli-surface-checkunavailable, unchanged on this base: no such task in the repository's mise.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 ffi 738 (unchanged), capsule-sdk --features ffi 169 (+1). PASS
  • cargo nextest list --workspace — 1932, matching the run and the expected delta exactly. 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)'9/9 passed, membership row included. PASS
  • mise run check-docs-truth (module-paths 119, all resolve), check-md (0 issues), check-docs (build Complete) — PASS
  • mise run check-commits origin/feat/postgres-adapters-402 — no errors in 19 commits. PASS
  • No failure at the final head. The only red observed this round was the two deliberate falsifications above, both reverted.

Second confirming-review round (head 5fcd4a6c). Same worktree and CARGO_TARGET_DIR; gen-bindings with 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). PASS
  • cargo nextest run -p capsule-server --test ops --test upload --test roster — 68 passed. PASS
  • cargo nextest run -p capsule-server -E 'test(membership) | test(roster)' — 39 passed. PASS
  • Falsification of 39 — restored ..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.
  • Falsification of 40 — check present (shipped): 3 member-write cases pass; check removed (decision 35's state): the attribution refusal fails, 2 passed / 1 failed. In the state the review found — check present, core unfixed — it was a_writer_members_op_... that failed instead. Both falsifications reverted, tree confirmed clean.
  • mise run doc-check-rustfirst run exit 101, redundant explicit link target at provenance.rs:33, classified caused (a doc link added by decision 39). Fixed at 5fcd4a6c; 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. PASS
  • mise run gen-bindings (CARGO_TARGET_DIR unset) — exit 0, tree clean afterwards. PASS
  • mise run cli-surface-checkunavailable, 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 ffi 740 (+2), capsule-sdk --features ffi 169 (unchanged). PASS
  • cargo nextest list --workspace — 1935, matching the run and the expected delta exactly. PASS
  • DOCKER_HOST=… CAPSULE_TEST_CONTAINER_USERNS=keep-id CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)'9/9 passed. PASS
  • mise run check-docs-truth, check-md, check-docs — each exit 0. PASS
  • mise run check-commits origin/feat/postgres-adapters-402 — no errors in 22 commits. PASS
  • One failure this round, caused and fixed: the doc link above. Everything else green at the final head.

Risks and rollout

  • Additive module on the frozen capsule-core (crypto::membership, one public path, see decision 23).
  • WriteAuthority::album_write_access and CursorCodec::{encode,decode} change signature; every implementor/call site in the workspace is updated. A cursor minted before this change fails as NotAuthentic (one full resync, the same event a key rotation is; cursor version byte unchanged, decision 20).
  • Operator-facing: every sync cursor issued before this deploy is refused. The MAC now covers a 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 into design/import/download-sync.md's cursor-authenticity section; there is no deployment/upgrade note among the docs this PR touches (guides/self-hosting.md exists but is outside this lane's manifest).
  • A roster version is now bounded above. A publish more than MAX_ROSTER_VERSION_STEP (16) past the held version is refused with 400 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 at current_version + 1, which the refusal names.
  • POST /v1/albums/{album_id}/ops refuses more than it did, in two ways: a suspended caller gets 403 error.moderation.account_suspended, and an envelope whose created_by_user is not the authenticated caller gets 400 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/upload answers the same 400 for the same field. A client that attributes a write to another account stops working on either surface, deliberately.
  • Clients older than decision 39 cannot produce an acceptable continuation from a second device at all. Before that fix, sign_lifecycle inherited created_by_user/created_by_device from 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 fails verify_asset step 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.
  • A roster refusal no longer echoes the declared version as a number. spargen lowers every integer in the contract as i64, so an out-of-range extension member made the generated client fail to decode the problem and lose the code (decision 37). The value rides the English detail instead; current_version and max_version remain, bounded by MAX_ROSTER_VERSION. A client reading declared off the JSON — none exists in this repo — would need the detail.
  • A removed member keeps paying for what they wrote, and nothing on the removal path reclaims it — only the refcount collector does, and only once the owner deletes the asset. Recorded in membership/mod.rs and routes/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.
  • Migration ordinal 5 adds two tables; serve refuses to boot until capsule-server-migration up has applied it (existing assert_schema_current).
  • Bytes shared across unrelated owners are still decided from the first live reference — 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.
  • W-FED (server: federation — capabilities, the pull path, and the sync capability gate (S-E2, S-E5, S-C49) #406) stacks on 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_lifecycle inherits created_by_device from the chain head while signing with the current device, so any continuation by another device fails verify_asset).

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.

Decisions taken on the orchestrator's review findings (710867a9 → head), same shape:

  1. A roster version is bounded above as well as below (F1)
    Taken: MAX_ROSTER_VERSION_STEP = 16; precheck refuses a version more than that above the held one and returns RosterOutcome::VersionLeap { current_version, max_version }, reading a held version of 0 for an album with no roster so a first publish cannot latch the counter either. The route renders it 400 error.album.roster_version_leap carrying declared, current_version and max_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 at current_version + 1, which says exactly the same thing (absence at a higher version is removal). Pinned by two conformance cases (both adapters), three precheck unit 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: 409 beside error.album.roster_stale (a 409 on 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 the 400 carries — 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 at u64::MAX wedges an album whether or not it is the first).
    Reverses: the unbounded roster_version of decision 1 and its AlbumRoster doc 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.

  2. apply_op consults moderation standing (F2)
    Taken: POST /v1/albums/{album_id}/ops asks the moderation port for the caller's standing on the same seam POST /v1/upload uses — before the write authority, the quota and anything stored — and renders the same 403 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.

  3. created_by_user must 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_id against the path, the metadata hash against the bytes in hand). 403 on these surfaces is about capability; this is a request contradicting what the request itself establishes, and choosing the 400 keeps the two readings apart. Applied to POST /v1/upload as 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); a GateReject variant in the shared battery (GateContext is 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.

  4. A removed member's storage is recorded, not reclaimed (F6)
    Taken: the behaviour is written where it is observable — membership/mod.rs module docs and routes/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's release_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.

  5. The SDK's roster publish goes through the generated operation (F7)
    Taken: AlbumClient::publish_roster now calls the generated rest::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). A 401 under a session is refreshed once and replayed, the way SyncConsumer::pull does it, because a per-request provider's pre-flight refresh cannot cover a token revoked mid-flight. Two cases added: the 400 leap refusal surfaces its current_version, and the request's method, path and required X-Capsule-Protocol are asserted to be the document's.
    Rejected: recording an exception (there is no generator gap — publish_album_roster is plain JSON and spargen lowers it; the only gap in this area is application/cbor, which this operation deliberately does not use, decision 6); routing provision through 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.

  6. 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 commits d7d6f532 and a11d280a, not by any commit of this lane: build_client now takes its reqwest::Client from crate::net::http_client() (so the feed pull carries the protocol handshake, and the constructor gains a failure arm it did not have), and sync_feed takes the protocol date as its first argument because the document declares the header required. There is no capsule-sdk/src/sync/mod.rs in this tree; the module is capsule-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.

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

  8. 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 rollout above: 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 as NotAuthentic rather than Malformed, 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.

  9. 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 the 400's declared/current_version/max_version are described by the contract rather than merely emitted under additionalProperties: 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, the mise run i18n outputs). Separately, 137fa2e5 fixes a divergence the container suite caught: PostgresMembership::apply_roster narrowed the counters to BIGINT before precheck, so the very version the new window refuses came back as a storage 500 where 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.md row deltas (the counts paragraph is W-FINAL's and is untouched by this PR): S-C39 partdone; S-C51 blockeddone; S-C25 stays done* 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:

  1. The author check comes off apply_op and stays on create_uploadREVERSED 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 from sign_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 from verify_asset's step 6–8 coupling, which is the reason that survives).
    Taken: What each surface admits. POST /v1/albums/{album_id}/ops admits exactly delete | trash-restore | metadata-update | derivative-add | derivative-replace (the allow-list in capsule-server/src/upload/envelope.rs:403-409), none of which may carry a null prior_provenance_hash — every one is a chain continuation. POST /v1/upload admits exactly create and replace (the dispatch at capsule-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 builder Workspace::sign_lifecycle (capsule-core/src/lifecycle/provenance.rs:26-46) fills every field it does not override from the chain head (..base.clone()), overriding only action, prior_provenance_hash, retention_until, metadata_blob_hash, timestamp and client_version — the adjacent comment says client_version is per-write "not the creator's", i.e. the code stating that created_by_user is 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:500 all write created_by_user: self.account.user_id — the last for an adopted web-upload drop, which design/web-upload.md:91 spells out ("set created_by_user/created_by_device to 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 on create_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-68 rather than left to be inferred: (1) the bearer token; (2) standing (decision 27); (3) write capability — album_write_access answers owner-or-writer-member for this caller on this album, one indistinguishable 403 for reader, former member and stranger; (4) invariant 7created_by_device must 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 check device_sig at all and therefore cannot verify attribution — that the account a record names really signed it. That is capsule_core::crypto::verify_asset's job on the client, which resolves created_by_device inside created_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-C51 exists to admit); moving the comparison into the shared gate battery (GateContext is also built by the anonymous drop-link upload at routes/drop.rs:1140, which has no authenticated caller, so a required caller there would be a lie); dropping the check from create_upload too (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_op half of decision 28. The create_upload half 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 #475sign_lifecycle inherits created_by_device from 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_asset refuses 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.

  2. The test that hid it, rebuilt — and falsified
    Taken: bobs_delete (capsule-server/tests/ops.rs:609-641) no longer overwrites created_by_user; it asserts the envelope inherits the owner, and overwrites only created_by_device to 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 answered sync_seq, and the record still names the owner; its second arm swaps the device back to the owner's and asserts 400 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_op makes both that case and the pre-existing a_writer_members_op_is_filed_under_the_owner_and_reaches_the_owners_feed fail (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::Workspace inside a server test. Stated plainly because it is a deviation from the instruction's literal wording: this surface never parses manifest_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_delete helper of decision 28's commit.

  3. Every counter the server can emit must be one a generated client can decode
    Taken: Three changes, smallest first. declared stops being an extension member (capsule-server/src/routes/roster.rs:118-131) and rides the English detail: 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 a BIGINT stores and what a generated client reads — by construction, not by argument. And the extension table learns format (capsule-server/src/openapi/mod.rs:90-110), so the two remaining counters are described as the u64s they are; RosterStaleProblem.current_version had 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 i64 and emits no u64 anywhereRosterResponse.roster_version carries format: uint64 and minimum: 0 and is still generated as pub type RosterResponserosterVersion = i64 (verified in the generated rest_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_version the SDK yields Transport("response decode failed at invalid value: integer 18446744073709551615, expected i64") — the code and the hint gone, indistinguishable from a network fault — and with the shipped bound it yields the typed refusal. Guarded at both ends: assert_decodable walks the whole problem body in capsule-server/tests/roster.rs:88-106 (so a future extension that forgets the rule fails there, not just the members this case names), and the_widest_version_the_server_can_name_still_decodes (capsule-sdk/src/albums/tests.rs:449-492) pins the SDK at i64::MAX — the boundary, not a comfortable value in the middle.
    Rejected: adding an SDK case with declared above i64::MAX as literally requested — the field is no longer on the wire, and RosterVersionLeapProblem is additionalProperties: 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 declared extension member.
    Residual: any integer extension in this document is still i64 to a generated client, so a non-Capsule server (or a proxy) sending an out-of-range one degrades to AlbumError::Transport. That is spargen's lowering, not this contract's, and is out of scope here.

  4. 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) pins held = u64::MAX exactly: every publish is a VersionLeap — including u64::MAX itself, which is not treated as a replay, because the ceiling is decided before the version comparison — while a version inside the storable range is Stale. The clamp added in decision 37 makes that state unreachable rather than merely improbable: no roster above MAX_ROSTER_VERSION is 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 no max_version the route can render is outside i64, checked across held versions that cannot occur so the property does not depend on the ceiling being enforced elsewhere.
    Rejected: leaving saturating_add with a comment (at held = u64::MAX it collapses max_version onto current_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_add and its held = u64::MAX - 1 test, 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:

  1. sign_lifecycle names 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 source
    Taken: capsule-core/src/lifecycle/provenance.rs:26-70 re-mints created_by_user and created_by_device per write from the acting account and signing device, as all three create paths already do, instead of inheriting them from base via ..base.clone(). The client_version distinction is kept intact — it was right, and it was the code stating the rule the two identity fields then contradicted. The field docs at capsule-core/src/crypto/provenance/manifest.rs:129-150 are corrected from "User who produced the asset" to what the verifier actually requires, citing the coupling and saying where the creator is recoverable (the create record 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 unless created_by_device resolves inside created_by_user's directory; step 8 rejects unless device_sig verifies under that entry's key. A record naming anyone but its own signer is unverifiable by every reader. design/web-upload.md:91 says 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 rides write_sig at 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_verifies and a_members_continuation_verifies_under_the_members_own_directory (provenance.rs:335-470), both asserting VerifyOutcome::Accept and 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); changing verify_asset to resolve against a writer's directory instead (that needs extra signed fields and is a schema change under a new protocol_version, for no gain over naming the signer).
    Reverses: nothing in this PR; it corrects capsule-core behaviour that predates it. Manifest widened by the orchestrator to capsule-core/src/lifecycle/provenance.rs and the two field docs in capsule-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.

  2. 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, so created_by_user != caller is a true structural refusal again and stops a member attributing a write to the owner or to a third account — on a surface S-C51 opened 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_op half.

  3. 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 rollout says "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

…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: 5fcd4a6
Status: ✅  Deploy successful!
Preview URL: https://ca21ec55.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.
`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.
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