[FEAT] Server-side album membership (S-C51) - #458
Open
justin13888 wants to merge 28 commits into
Open
Conversation
…ix/protocol-headers-every-route-404
…ix/protocol-headers-every-route-404
…rceptor pair The design puts three request headers and three response headers on every route; the server declared the request half on four upload operations and never sent the response half at all. A Kynos ApiError has no response-header seam, so routes/upload.rs rode X-Capsule-Protocol-Min/-Max as problem extension members while capsule-sdk reads them from headers and got None. The seam is on the interceptor. negotiation.rs adds two: Negotiation, mounted router-wide outside the body-size limit, attaches the window to every response the chain produces, errors and short-circuits included; ProtocolGate, on a Group, reads the three request headers and refuses 426 or 400 before the handler runs. Both read the one UploadPolicy window, which gains the advisory min_client_build. The group holds the four upload session operations that enforced the handshake per route until now; routes/upload.rs loses that duplication, and the 426 keeps no window members in its body. openapi::describe_negotiation_headers files the three response headers under every response of every operation, since Kynos describes an interceptor's headers on success responses only. The test fixture's client sends the handshake on every request with raw() for its absence, and conformance.rs gains a document census and a document-driven wire census that pin the gated and exempt sets. Refs #404
boot::assemble built the upload policy from UploadPolicy::default() regardless of PROTOCOL_MIN and PROTOCOL_MAX, so a deployment that narrowed its window published one range on /.well-known/capsule/server-info and enforced another on POST /v1/upload. The policy is now built from the configured window, which is also what the negotiation interceptors advertise and refuse against. The new boot test reads both back through the surface. Refs #404
Every gated operation requires X-Capsule-Protocol and refuses without it. The shared reqwest client behind the generated REST client now carries that header and X-Capsule-Crypto-Suite as defaults, from the build's own constants, so the generated operations and the hand-written paths over the same transport send them with no per-call argument. protocol_headers() is public so the SDK's other transports can carry the same handshake from the same source. Refs #404
The ProtocolGate group now holds every non-exempt operation; GET /v1/version,
the four /.well-known/capsule/* records, the three /s/{opaque_id}* reads and
the two /d/{opaque_id}* guest deposits stay on the router and carry only the
response headers. The census in tests/conformance.rs asserts the gated set is
exactly the complement of the pinned ten, the coverage walk produces the 426
and 400 every gated operation now declares, and one representative route per
module is refused before a credential or body is read. The boot tests that
register and sign in send the handshake, as every client does.
Refs #404
net::http_builder installs X-Capsule-Protocol and X-Capsule-Crypto-Suite as default headers once; http_client builds it and dial_client adds its connect timeout on top of the same builder. AuthClient::new, the sync consumer, the generated client behind AuthenticatedClient and the CLI's version probe all obtain their client there, so no SDK request reaches a gated route without the handshake. The document now declares X-Capsule-Protocol required on every gated operation, which puts the protocol date first in each generated signature; the SDK's callers pass the build's own constant, the same value the transport sends, and the sync consumer maps the 426 the feed can now answer. Refs #404
reqwest::ClientBuilder is already must_use; clippy's double_must_use refuses the second attribute under the repo's -D warnings. Refs #404
The key-free server cannot read the MLS roster, so it has had no membership fact to gate the blob route or the album write routes on. `crypto::membership` is the artefact that gives it one: a full-roster document with a strictly monotonic `roster_version` and the AMK epoch it reflects, signed by a device in the album owner's published device directory and verified the way the upgrade intent is, with the added rule that a revoked device may not attest. It lives in `capsule-core` because a client signs it; the server only verifies. One public path, `capsule_core::crypto::membership`, under the #399 freeze.
Deploying capsule with
|
| Latest commit: |
710867a
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://703fc03e.capsule-22k.pages.dev |
| Branch Preview URL: | https://feat-server-album-membership.capsule-22k.pages.dev |
…ration `membership::MembershipStore` holds the one fact the key-free server has about who may read and write a shared album: the consequence of the owner-signed roster. `apply_roster` compares versions and replaces the roster in one critical section — a mutex in memory, a per-album transaction lock in Postgres — and a member the owner removes keeps a row marked with the version and epoch at which they vanished, which is what lets the blob route render `403` for a former member while every stranger still sees the unknown-address `404`. Migration ordinal 5 adds `album_rosters` and `album_members`; the compiled-in `EXPECTED_MIGRATIONS` gains it; one conformance suite runs against both adapters, the Postgres case under the container gate; the boot compose test constructs the Postgres adapter beside the other four. The context is wired into `App` and the fixtures so the roster route can follow without touching the composition again.
…eat/server-album-membership-405
… window threat-model/validation.md scopes the 426 to a write and says reads of any past version succeed. The gate is now two: ProtocolGate on the group of non-safe operations refuses a grammatical protocol date outside the window with 426, and ProtocolReadGate on the group of GET and HEAD operations admits any grammatical date and refuses only a missing or malformed handshake with 400. Because an interceptor's declaration is its type, a read now declares the 400 alone and no 426 it never renders; the census asserts exactly that, the coverage walk and the per-module table follow the split, the 413 loop and a new 401 case assert the window headers on refusals the gates did not make, and the feed's dead 426 arm leaves the SDK's sync consumer. Refs #404
PROTOCOL_MIN and PROTOCOL_MAX defaulted to the single day capsule-core speaks, collapsing the window a fresh deployment accepts to one date, and neither was checked for shape, so 2026-6-1 sorted before 2026-12-31 for the wrong reason. Both now default to the policy's year window, parse as strict YYYY-MM-DD dates, and may be equal. MIN_CLIENT_BUILD joins them, validated as MAJOR.MINOR.PATCH and handed to the upload policy, so the advisory X-Capsule-Min-Client-Build on every response is the operator's value rather than the crate default. With both validated at the boundary, an unencodable window value is a programming error and the response encoder says so. Refs #404
Every transport constructor that accepts a reqwest::Client names net::http_builder as its source; a client built any other way sends no protocol handshake and every gated route refuses it. Refs #404
Every gated route now refuses a request without X-Capsule-Protocol, and the hand-written web auth client sent none, so sign-in, registration, refresh and every authenticated call from the browser were refused. All five request builders send the protocol version this client is written against, restated from capsule_core::crypto::primitives::PROTOCOL_VERSION because the browser holds no Rust and the wasm surface does not export it. Refs #404
Four `error.album.roster_*` codes for `PUT /v1/albums/{album_id}/roster`
— malformed, attester, not_found, stale — in the source catalog only,
as every other `error.*` code the server emits is; the per-platform
files are the generator's output.
The one way the key-free server learns who may read and write a shared album. The owner account PUTs a `SignedAlbumRoster` as base64 canonical CBOR; the route refuses anything decidable from the request alone as `400`, answers a non-owner with the album ceremonies' `404`, verifies the attester against the owner's published device directory exactly as the upgrade ceremony verifies its proposer (`403`), and applies the roster through the membership port — `200` with `replayed` for the same bytes, `409` carrying `current_version` for a version or epoch that does not supersede the held one. JSON with base64 rather than an `application/cbor` body so spargen generates the operation and the SDK needs no hand-written request. The route is mounted inside the protocol-gated group; the conformance walk drives every declared response; invariant 33 and the idempotency row land with it because the endpoint census resolves docs against the regenerated document.
api.ts is hand-written, so the header could silently go missing again. A recording mock of the global fetch drives the five request builders and checks X-Capsule-Protocol equals PROTOCOL_VERSION on each, and PROTOCOL_VERSION is compared with the literal in capsule-core/src/crypto/primitives.rs read at test time, so the restated constant cannot drift from its source of truth. Refs #404
`WriteAuthority::album_write_access` is keyed on the caller and answers
whose album it is: `Writable { owner_id, role }` for the owner or for a
writer on the album's roster, one `Denied` for a reader, a former
member, a stranger and an unprovisioned id. `ProvisionedAuthority`
consults the membership store for the widening `S-C25` deferred.
Every write files under the album owner's namespace, because the
owner's feed is the one every member's devices read, and bills the
uploader, who spent the bytes. The upload route takes the namespace
from the authority and lets a declared `owner_id` only agree with it;
the ops route files a member's op under the owner; adoption stays in
the link owner's own album; finalization re-asks for the uploader, so
a member unshared mid-transfer is refused where a closed album is.
…-album-membership-405
…eat/server-album-membership-405
`MembershipAuthority` replaces `OwnedAssetAuthority`: an account reads the blobs of its own assets and of every album whose current roster names it, in either role; an account the roster once named and no longer does is `BlobReadAccess::Revoked`, rendered as `403 error.blob.access_revoked` — the authorization-change signal the download contract describes, which `S-C39` left unwritable for want of exactly this fact. Everyone else still gets the `404` an unknown address gets, body and headers alike, and the authority is still asked before every `410`, so a former member learns nothing about holds or deletions either. `BlobReference` carries the asset's `album_id` so the decision comes from the same read that found the reference. Bytes shared across unrelated owners are still decided from the first live reference, which this widens from a wrong `404` to a possible wrong `403`; filed as #462 rather than fixed here. Closes the S-C39 done-when; the download-sync and authorization docs describe what the server now decides.
…c?album_id= A member has to learn the addresses in a shared album before the blob route can serve them, and the owner's feed is the owner's. With `album_id` the feed pages the **owner's** sequence filtered to one album, for the album's owner or any account on its current roster — positions stay per-album monotonic, as the client's anti-rewind mark requires, and the album's head is its own last entry rather than the owner's allocator. Unprovisioned, never-a-member and removed are one `403 error.sync.album_access_denied`, as the write routes answer. The cursor MAC now covers a `CursorScope` — the caller and, for an album page, the album — with the caller length-prefixed, so a cursor minted for one shape cannot be presented on the other. `AssetIndex` gains `album_feed_page`/`album_head_seq` in both adapters with one conformance case; the sync context gains the album and membership stores. Closes the S-C51 done-when.
…r scope `album_feed_page` and `album_head_seq` take the owner the album record names as well as the album, so the `(owner_id, album_id)` index serves both queries and a row another account filed under the same album id is not this album's; the route hands the owner over from the access decision it already made. The cursor module's header now states the scoped MAC input and why the version byte did not move; the sync doc names the position-gap disclosure the album page accepts; the route cases publish past the album's head so `has_more` is proved against the album and not the owner's allocator.
`AlbumClient::publish_roster` sends a `SignedAlbumRoster` — signed in
`capsule_core::crypto::membership` by the owner's device — as base64 of
its canonical CBOR on `PUT /v1/albums/{album_id}/roster`, and maps the
reply onto `PublishedRoster`. Orchestration only: the bytes the device
signed reach the wire verbatim, a refusal carries the server's `error.*`
code, and an echo for another album is malformed rather than a success.
`AlbumError::Status` gains `current_version`, read from the `409 error.album.roster_stale` problem body, so a caller can republish above the version the server holds instead of guessing; the album errors read as the surface's rather than provisioning's, and the wire test asserts the bytes are canonical and standard-base64 encoded.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Server-side album membership (
S-C51, closingS-C39's403): the owner-signed roster attestation incapsule-core, the membership port with in-memory and Postgres adapters (migration ordinal 5),PUT /v1/albums/{album_id}/roster, album writes widened to writer members, the blob route's403for a former member,GET /v1/sync?album_id=for members, andAlbumClient::publish_rosterin the SDK. Base is #447 (feat/postgres-adapters-402); #453 is merged in by sibling merge.Summary
capsule_core::crypto::membership—AlbumRoster/SignedAlbumRoster::{sign, verify}; canonical-CBOR signing bytes; a revoked device may not attest.capsule_server::membership—MembershipStore::{apply_roster, membership, current_roster},InMemoryMembership,PostgresMembership(per-album advisory transaction lock), one conformance suite,m20260902_000005_album_membership,EXPECTED_MIGRATIONS+= 1, boot compose test.PUT /v1/albums/{album_id}/roster(JSON, base64 canonical CBOR; 200/400/403/404/409+current_version/500), mounted in theProtocolGategroup; fourerror.album.roster_*codes.WriteAuthority::album_write_access(caller, album) → Writable { owner_id, role, … }; a writer member files under the owner's namespace and is billed as uploader; ops/upload/adoption/finalization call sites.BlobReference.album_id;MembershipAuthority(replacesOwnedAssetAuthority);BlobReadAccess::Revoked→403 error.blob.access_revoked; never-members still get the byte-identical404.AssetIndex::{album_feed_page, album_head_seq}bound to(owner, album);CursorScopein the cursor MAC;GET /v1/sync?album_id=;403 error.sync.album_access_denied.AlbumClient::publish_roster,PublishedRoster,AlbumError::Status.current_version.authorization.md(Album Membership on the Server),import/download-sync.md,threat-model/validation.md(invariant 33 + idempotency row),api-surfaces.mdrow;SLICES.mdrows/blocks S-C51 (done), S-C39 (done), S-C25 owed line. The counts paragraph is W-FINAL's; delta from this PR: S-C39part→done, S-C51blocked→done.Validation
Worktree
/var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-server-album-membership-405, head710867a9. After the host deletedtarget/, every cargo/mise command ran withCARGO_TARGET_DIR=/var/tmp/capsule-lane-405/target(exceptgen-bindings, which hardcodestarget/).cargo nextest run -p capsule-core membership— 15 passed (9 new). PASScargo nextest run -p capsule-server -E 'test(membership) | test(expected_migrations)'— 20 passed. PASScargo nextest run -p capsule-server --test roster --test conformance— 19 passed (10 roster + walk). PASScargo nextest run -p capsule-server --test upload --test ops --test albums --test drops --test upgrade— 85 passed. PASScargo nextest run -p capsule-server --test blob --test conformance— 37 passed. PASScargo nextest run -p capsule-server --test sync --test conformance --lib -E '…index::|sync::'— 63 passed (with SDK album cases). PASScargo nextest run -p capsule-sdk -E 'test(albums)'— 13 passed. PASSmise run check-commits origin/feat/postgres-adapters-402— no errors in 10 commits. PASSmise run check-rustat7111c588(before the host deletedtarget/) — exit 0. PASSmise run check-rustat710867a9— killed at the 10-minute cap duringbuild-ruston a cold rebuild (exit 143):unclassified(host load), so its sub-tasks were re-run individually:format-check-rust,lint-check-rust,doc-check-rust,i18n-check,i18n-guard,openapi-check-kynos,architecture-check,license-check,translate-readme-checkcompleted inside the aggregate before the kill (the runner stops at the first failure) — PASS;mise run build-rustexit 0;mise run build-check-wasmexit 0;mise run build-ffiexit 0;mise run lint-check-ffiexit 0;mise run gen-bindingsexit 0 (tree clean afterwards);mise run verify-examplesexit 0. PASSmise run test-rustat710867a9— exit 0: workspace 1916 passed;capsule-core --features ffi738 passed;capsule-sdk --features ffi166 passed. PASSDOCKER_HOST=unix:///run/user/2000/podman/podman.sock CAPSULE_TEST_CONTAINER_USERNS=keep-id CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)'at710867a9(imagepostgres:18per [FEAT] Postgres adapters and a conformance suite for four durable ports #447's harness) — 9 passed, includingmembership::postgres::…::the_postgres_membership_store_conforms,index::postgres::…::the_postgres_index_conforms,postgres::…::the_migrations_apply_and_roll_back,boot::…::every_postgres_adapter_composes_from_the_boot_configuration. PASS (also 9/9 ate88a8b2a,c93f4886.)mise run check-docs-truth— cross-links 476, endpoint-census 101, module-paths 119, all resolve. PASSmise run check-md— 0 issues. PASSmise run check-docs— build Complete. PASScargo clippy --all-targetsoncapsule-core/capsule-serverreports pre-existing test-code lints in files this PR does not touch (src/counter/tests.rs,src/gc/tests.rs,tests/ops.rs:115,tests/sync.rs:295, corelocal_gallery_security); the repo'slint-check-rustdoes not run--all-targets.pre-existing.Risks and rollout
capsule-core(crypto::membership, one public path, see decision 23).WriteAuthority::album_write_accessandCursorCodec::{encode,decode}change signature; every implementor/call site in the workspace is updated. A cursor minted before this change fails asNotAuthentic(one full resync, the same event a key rotation is; cursor version byte unchanged, decision 20).serverefuses to boot untilcapsule-server-migration uphas applied it (existingassert_schema_current).MembershipStore,CursorScope,album_feed_page,BlobReadAccess; names are stable as pushed.Related Issues
Refs #405. Refs #462 (filed by this PR: shared bytes across owners on the blob route; member pending-upload answer).
Contributor Checklist
Decisions taken
Decisions taken inside the manifest during delivery (same shape):
error.*keys are added tolocales/en.jsononlyTaken: the six new keys go in
en.json; the source catalog already carries 133error.*keys against 90 in every other catalog, so the generator accepts source-only error codes and the twelve other catalogs fall back to the source locale.mise run i18nregenerates the per-platform files.Rejected: hand-writing twelve translations this lane cannot verify (the plan assumed thirteen translations; the tree shows that is not the rule for
error.*).Reverses: the plan's "13 translations" assumption.
amk_epochiscapsule_core::crypto::keys::AmkVersion, not a bareu32; the server port stores it asu64Taken: the roster carries the existing newtype so the epoch is the type the manifest's
amk_versionuses; the port and columns areu64/BIGINTlike every other counter.Rejected: a raw
u32(a second spelling of one concept in a frozen crate).Reverses: nothing.
PostgresMembership::apply_rosterserialises onpg_advisory_xact_lock(hashtext(album_id)), notSELECT … FOR UPDATETaken: a transaction-scoped advisory lock keyed on the album id, taken before the read; released by commit or rollback (the refusal arms return without writing).
Rejected:
FOR UPDATEon the roster row — the row does not exist for an album's first roster, so two first publishes would both upsert and the loser would silently overwrite the winner rather than answerStale.Reverses: the plan's "
SELECT … FOR UPDATEon the roster row".The server port speaks
capsule_core::crypto::membership::MemberRole; the column tokens are the server'sTaken:
membership::MemberRoleis a re-export of the core type;role_token/role_from_tokenin the server own thereader/writerstorage tokens.Rejected: a second server-side enum with a conversion.
Reverses: the plan's separate
pub enum MemberRolein the port.RosterOutcome::EpochRegressedcarriescurrent_version; the route renders it as the same409 error.album.roster_staleTaken:
prechecksetscurrent_versionfrom the held roster, so the route names the held version without a second read outside the critical section.Rejected: a second
current_rosterread (could name a later version than the one that refused; a second500path).Reverses: nothing.
The roster body must be canonical CBOR, and the size cap is applied to the encoded string first
Taken:
roster_cborlonger thanMAX_ROSTER_BYTES / 3 * 4 + 4is refused before decoding; decoded bytes that are not their own canonicalization are400 error.album.roster_malformed, because the store decides replays on bytes.Rejected: accepting non-canonical documents (a byte-different re-encoding of the same roster would be a
409, not a replay).Reverses: nothing.
A declared
owner_idonPOST /v1/uploadmay only agree with the album's ownerTaken: the authority answers the namespace;
resolve_owner(uploader, owner, declared)accepts absent or the album owner (the uploader's own id only when they are the owner); anything else — a member naming themselves included — is403 error.upload.owner_not_permitted.Rejected: accepting a member's own id (an asset under the member's namespace is one the owner's feed never carries).
Reverses: the plan's "declared
owner_id∈ {absent, uploader, album owner}" for the member case.Readers read; only writers write; finalization re-asks for the uploader; adoption stays owner-only
Taken:
MembershipAuthoritygrants either role;ProvisionedAuthorityadmits onlyMemberRole::Writer;finalize::revalidatekeys the re-check onrecord.upload_user_idand refuses anowner_idthat no longer matches; adoption requiresWriteRole::Owner.Rejected: keying finalization on
record.owner_id(would pass a member who was unshared mid-transfer).Reverses: the plan's call-site list, which missed
upload/finalize.rsandupload/mod.rs's re-export.OwnedAssetAuthorityandowned_assets()are removed, not kept besideMembershipAuthorityTaken: one production read authority;
membership_reads(members)is the wiring helper;BlobReferencegainsalbum_id.Rejected: keeping the owner-only authority as a second implementor with no call site.
Reverses: nothing.
Serialised behind [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453 by sibling merge, and the base by merge commit
Taken:
origin/feat/postgres-adapters-402merged atb09fad7f(mergeccc0e351) and at its finald2a789d7(merge3f645bc7);fix/protocol-headers-every-route-404merged at7526395b(merge0a55362b) and at its final032b6af2(merge0e44b407);lib.rsresolved by keeping [FIX] Advertise and gate the protocol handshake on every route from one interceptor pair #453's two-gate router and mountingpublish_album_rosterin theProtocolGategroup's library-writes mount;openapi.jsonregenerated withmise run openapi-kynosat each merge, never merged.Rejected: rebasing; merging the JSON.
Reverses: nothing.
An album's page is bound to
(owner, album)and the cursor MAC covers aCursorScopeTaken:
album_feed_page(owner, album, after, limit)/album_head_seq(owner, album)so the existing(owner_id, album_id)index serves them and a row another account filed under the same album id is excluded; MAC inputpayload || u32 BE len(caller) || caller || 0x00 | 0x01 || album; cursor version byte unchanged (an old cursor fails asNotAuthentic, the same one-time full resync a key rotation is); the position gaps on an album page disclose only how many changes the owner made elsewhere, accepted and documented.Rejected: a per-album numbering (a second sequence the client's anti-rewind mark would have to reconcile); bumping the version byte (would make old cursors
Malformedinstead ofNotAuthentic, a distinction the client does not act on).Reverses: the plan's
album_feed_page(album, after, limit)shape.GET /v1/sync?album_id=decides access before decoding the cursor, and answers one403for every refusalTaken: unprovisioned, never-a-member, removed (and a non-canonical id, which is simply unprovisioned) →
403 error.sync.album_access_denied; a foreign or malformed cursor on a page the caller may read is the existing400.Rejected:
404for the album, or a400for a non-canonical id (would distinguish an id space the write routes keep uniform).Reverses: nothing.
The SDK's
publish_rostermirrorsprovision's hand-written wire DTOs and surfacescurrent_versionTaken:
AlbumClient::publish_roster(&SignedAlbumRoster)sends base64 canonical CBOR onPUT {base}/{album_id}/roster;AlbumError::Statusgainscurrent_versionfrom the409body.Rejected: routing through the generated
rest::Client(the album client's transport is the session-backed oneprovisionalready uses; two transports for one surface).Reverses: nothing.
Freeze accounting for
capsule_core::crypto::membership(core: freeze the capsule-core public API and remove the dead surface #399)Taken: one public path,
capsule_core::crypto::membership::{MembershipError, MemberRole, RosterMember, AlbumRoster, SignedAlbumRoster}, no re-export at thecryptolevel; every item documented (the doc gate runs over private items). Call sites:capsule-server(routes/roster.rs,membership/mod.rsre-exportsMemberRole, tests),capsule-sdk(albums.rs, tests).SignedAlbumRoster::signis what a client and the SDK tests call;AlbumRoster::signing_bytesis whatsign/verifyand the server's tests use.Rejected: keeping the type in
capsule-server(clients could not sign it).Reverses: nothing.
Bytes shared across unrelated owners are filed, not fixed
Taken: server: blob fetch is decided from the first live reference, so bytes shared across owners can answer a member 404 or a wrong 403 #462 records that
find_referencedecides from the first live row, now a possible wrong403as well as the pre-existing wrong404, plus the member pending-upload answer; proposed fix is afind_referencesfold inserve::resolve.Rejected: changing the index port in this PR.
Reverses: nothing.
Files touched beyond the manifest's literal list, each forced by a manifest change
Taken:
capsule-server/src/upload/mod.rs(re-export ofWriteRole),capsule-server/src/upload/finalize.rs(the fourthalbum_write_accesscall site),capsule-server/src/album/tests.rsandcapsule-server/tests/albums.rs(constructions ofAlbumWriteAccess::Writable),capsule-server/tests/support/fault.rs(theAssetIndexdecorator),capsule-sdk/src/albums/tests.rs, and the generator outputs ofmise run i18n(capsule-i18n/src/bundles/en.json,capsule-android/…/strings.xml,capsule-swift/Generated/Localizable.xcstrings,capsule-web/src/i18n/messages/en.json). None is a new surface; each is the compile-time or generator consequence of a change the manifest names.Rejected: stopping the lane for a re-plan over mechanical consequences.
Reverses: the manifest's file list, by those entries.
Unresolved review notes
apply_opnever checks moderationstanding()whilecreate_uploaddoes (pre-existing, noted by the slice-4 read).envelope.created_by_user == caller(pre-existing; invariant 7 binds the device to the caller's own directory).postgres/error.rsandpostgres/testing.rsmodule docs still say "four" adapters (outside this lane's manifest).