[FEAT] Server federation: capability tokens, the pull gate on sync and blob, and the lifecycle around them - #472
Open
justin13888 wants to merge 22 commits into
Open
justin13888 wants to merge 22 commits into
justin13888 wants to merge 22 commits into
Conversation
…e module context Land `capsule-server::federation` (S-E2, S-E5, S-C49): the EdDSA-JWT capability a peer presents on the existing sync and blob reads, the codec that mints and reads it under the same Ed25519 key session tokens are signed with, and the two ports behind it. `CapabilityStore` records every capability this server issued and implements `RevocationList`: the store is the list `/.well-known/capsule/revoked-jti` serves, so "is this jti revoked" has one answer. `PeerStore` holds the peers an operator has pinned and the server-level blocklist as a column on the same row. Both have in-memory adapters and one conformance suite, which now also carries the four cases the retired `discovery::revocation::InMemoryRevocations` had. The claim set is design/federation.md's table verbatim, with RFC 3339 instants checked against the injected clock, the 24 h ceiling enforced at mint and at verify, and every instant at whole seconds so a stored grant re-signs byte-for-byte for an idempotent refresh. A session token is unreadable to the capability codec and a capability to the session verifier, by construction. `FederationContext` joins `App`/`Modules`, `boot` and the test fixture; `boot` builds the codec from the same DER as the session signer and asserts the published key is the one capabilities verify under. `FEDERATION_URL` opts a deployment in and is published as `server-info.federation_url`. Four `error.federation.*` catalog keys are added for the lifecycle routes that follow, and `capsule-server::federation` leaves `planned-modules.txt`.
…server-federation-406
Deploying capsule with
|
| Latest commit: |
b8babdb
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://94501bfd.capsule-22k.pages.dev |
| Branch Preview URL: | https://feat-server-federation-406.capsule-22k.pages.dev |
…ility `GET /v1/sync` accepts a federation capability on the same `Authorization: Bearer` carriage a session token rides, through a second Kynos scheme registered under the identical `bearer` component: one entry in the document, one credential key in the generated SDK, two principals in the handler. The peer's arm is bound to the album its capability names — a peer has no own feed, so an absent or different `album_id` is `403 error.federation.audience_mismatch` — and the member the grant was minted for must still be on the roster at the epoch it was granted at, which answers the same `403 error.sync.album_access_denied` an account's arm does. Before any of that the capability is admitted: revoked is `403`, a blocked peer is `403`, a peer over its events-per-hour budget is `429`, and a collaborator that cannot answer is `500` and never an admission. The cursor scope becomes an enum with a third shape, `(peer, album)`, so a peer's cursor cannot be presented as an account's and one peer's cannot be presented as another's; the account shapes' MAC inputs are pinned to literals minted before the change, because the version byte was not bumped.
…ty's scope
`GET /v1/blob/{hash}` takes the same dual-principal bearer the feed does. A
peer is admitted first — revoked grant, blocked peer, spent events budget — and
then resolves through the path an account resolves through, authority first, so
a takedown `410` stays illegible to anyone not entitled to the bytes.
The read authority now decides from a `ReadPrincipal` rather than an account
id. A peer's relationship to an asset is its capability: the one album it
names, the roster member it was minted for, the epoch that membership was
granted at, and the scope. A blob in another album is `404`, byte-identical to
an address nothing references; a member the roster never carried is `404`; a
grant that outlived its membership — removed, or re-admitted at a later epoch —
is `403 error.federation.capability_revoked`, distinct from an account's
`error.blob.access_revoked` because the two say different things to do next;
and a role the grant does not cover is the new `403
error.federation.scope_insufficient`, which is how a `read-derivative-only`
capability is refused an `original` and every capability a `backup`.
The transient `409` stays account-only: it reports the caller's own device
still sending the bytes, and a peer has none here, so a peer is told what an
unreferenced address tells everyone.
Three lifecycle operations, and the revocation write behind the roster.
`POST /v1/albums/{album_id}/capabilities` mints for the album's owner and
nobody else — not yours is not found, the album ceremonies' answer. It needs no
key of the peer's: the grant is signed with this server's own operational key,
the one `server-info` publishes. Two facts are checked before anything is
signed — the peer is not blocked, and the member is on the album's current
roster — and the roster's `granted_epoch` is copied into the record, which is
what makes a member removed and re-admitted later unable to reuse the old
grant.
`DELETE /v1/albums/{album_id}/capabilities/{jti}` is idempotent and silent: a
`jti` this album does not hold answers the same `204` a revocation does, so the
operation is not a probe over identifiers, and the record's own album is
checked so a `jti` is not a handle on another album's grant. It is deliberately
**not** gated on `FEDERATION_URL`: turning federation off must not take away an
operator's ability to cut a grant already out there.
`POST /v1/federation/capabilities/refresh` takes the previous capability as its
credential. The store issues the successor, links the predecessor to it and
revokes the predecessor in one critical section, so a replay is answered with
the same token re-signed from its record — `(peer, jti)` idempotency without an
idempotency table. Admission grows a `Presentation`: a predecessor revoked *by
its own refresh* is admitted so the replay can be answered, and every other
revocation refuses both a read and a refresh.
Publishing a roster that omits a member now revokes every live capability that
member's access was minted for and publishes each `jti` to
`/.well-known/capsule/revoked-jti`, logged rather than surfaced — the roster is
the fact that operation answers for. An epoch bump that keeps the member cuts
nothing, and a takedown stays a per-asset serving hold.
…ked peer's grants `POST /v1/federation/reports` closes moderation's federated half. A reporting peer holds no capability here — it is reporting this server's content, not pulling it — so the report carries its own Ed25519 signature over the canonical CBOR of every other field, and it is verified against the key an operator has pinned for that peer. A server nobody has pinned is `403`: intake is not the moment a peer becomes trusted, which is why there is no TOFU fetch. The checks run in the order who, welcome, really, how often. The signature is verified before the budget is charged, so a third party spoofing `reporting_server` cannot spend a real peer's allowance; the budget itself is per `(reporting_server, reported_user)`, so a flood against one account cannot silence a peer that has something to say about another. Accepting a report writes a row an operator will read and nothing else. A peer's report is an input to a decision, never a decision: no standing changes, no serving hold appears, and the reported account's record stays empty, because nothing has been done to them. The signature is kept beside the report so it can be re-verified long after the fact. Blocking a peer now also cuts every live grant it holds and publishes each `jti`. The blocklist already refused at every boundary; what the cascade adds is that the block is legible on the record every peer polls, and that unblocking does not silently restore access the block ended.
…n Postgres Migration ordinal 6 and the two adapters behind it. `federation_capabilities` holds every grant this server minted; `federation_revoked_jti` is the list `/.well-known/capsule/revoked-jti` publishes. They are one port because "is this jti revoked" must have one answer — revoking sets `revoked_at` and publishes the jti in one transaction — and two tables because a revocation is also accepted for a jti no record backs, so the list cannot be a view over the records. Refresh and revoke each read a record, decide, and write both tables under a transaction-scoped advisory lock keyed on the predecessor's jti. `SELECT … FOR UPDATE` would not do: the successor's row does not exist yet, so two concurrent refreshes of one predecessor could both issue. `published` prunes past-expiry entries in the read's own transaction, exactly as the in-memory adapter retains. `federation_peers` carries the pinned key and the blocklist as a column on the same row, with the key nullable because blocking a server nobody ever pinned is legitimate. Both adapters pass the same conformance suite the in-memory ones do, and the boot composition check now proves seven adapters build from what the boot path already has.
…bility `capsule_sdk::federation::FederationPull` is orchestration over generated calls and contains no parser: the page comes back through the new `SyncConsumer::pull_album`, the bytes through a `CapabilityBlobSource` over the generated byte-serving `get_blob` fed into the same self-verifying `RangedFetcher` every download uses, and the lifecycle calls are the generated `refresh_capability` and `revoked_jti`. The pull is gated on a fresh revocation list and fails closed. A capability is a bearer token, so the only thing that can take one back before it expires is the issuer's published list; the puller refreshes its snapshot when the one it holds is older than the list's own `max_staleness_seconds`, refuses when it cannot refresh past that bound, and refuses when its own `jti` is listed. The freshness is measured on the peer's own clock — the party being checked does not get to say how old its list is. `refresh` swaps the token, its jti and both clients over under one lock and drops the snapshot: the predecessor is revoked the moment the successor is issued, so a puller holding one client on each would refuse itself on whichever request lost the race. `Debug` never renders the token. `SyncConsumer::pull_album` is the album arm of the same operation for both readers — an account on a roster and a peer under a capability — because the server is where the two differ and the client has one feed.
… still owes
The design docs described federation in the present tense while nothing
existed. They now describe what runs.
`federation.md` gains a status note: the serving half ships — mint, refresh
(idempotent on `(peer, jti)` by the predecessor-to-successor link), revoke, the
capability arm on the two read primitives, scope enforced against the blob's
server-visible role, and the store that *is* the revocation list. It also says
plainly what is owed: the egress pull worker and everything downstream of it,
and of the per-peer budgets only events/hour, because bytes and CPU need a
weighted counter the port does not have.
`api-surfaces.md` splits the one vague "Federation pull" transport row in two,
because the pull has no route of its own — it is `GET /v1/sync?album_id=` and
`GET /v1/blob/{hash}` with a capability in the `bearer` slot — and records that
the document carries **one** `bearer` component for both token types.
`validation.md` marks invariants 19, 21 and 24 enforced and says exactly how
far: 20 is not reachable while nothing pulls, and two thirds of 21 is not
built. `moderation.md` flips its status note now both federated halves exist.
`download-sync.md` names the peer as a third reader of an album's page, with
its own cursor scope. `module-map.md` points E2E case 4's server and SDK halves
at the files that carry them.
`SLICES.md`: S-E5 done, S-E2 and S-C49 part, each with what landed and what was
filed as #476. No rows added or removed.
`lint-check-rust` runs `cargo clippy --workspace` without `--all-targets` (issue #474), so no test target in this repository has ever been linted. Run with it, and three findings are this branch's own: a boxed-closure vector where plain function pointers do (and where the fixed-size array is the better statement — one entry per field of the signed claim), a `format!`-in-`collect` hex helper, and three `Duration::from_secs(900)` that read better as fifteen minutes, which is what the staleness bound actually is. Fixed here rather than left for #474 because they are new code. The findings that gate still hides in `capsule-sdk/src/auth.rs`, `capsule-server/tests/ops.rs`, `capsule-server/src/counter/tests.rs` and `capsule-server/src/gc/tests.rs` are pre-existing and are #474's to clear, not this branch's.
…resh cannot move Review finding H1. `refresh` minted every successor at `DEFAULT_TTL` and the store refused only a successor naming another peer, album or member — never a longer life. An owner who minted a deliberate sixty-second grant got a peer that refreshed inside the minute and chained indefinitely, so `ttl_seconds` was advisory for exactly one hop and revoking a `jti` the owner never saw was the only control left. The comment at the mint site asserted the opposite; it is corrected. **Renewability is now an explicit mint-time property.** `CapabilityRecord` carries `not_after`, fixed at the original mint, and `MintCapabilityRequest` gains `renewable_until` — absent by default, which makes the grant single-shot: `not_after == expires_at` and the first refresh is `403 error.federation.capability_expired`. Present, it must be in the future and within ninety days, and each successor is minted for `min(DEFAULT_TTL, not_after − now)`, so the last token of a grant ends *at* the deadline rather than overhanging it. The mint response states `not_after` and a plain `renewable` flag, because how long an owner is sharing for should not have to be inferred from two timestamps. The rule is structural, not the route's: `store::continues` refuses a successor whose `not_after` differs from its predecessor's and `store::admissible` refuses a token that runs past its own grant, both shared by every adapter, and a conformance case pins them — so an adapter cannot re-derive the check differently. Also review finding 27: the refresh re-checks the member's roster membership at the granted epoch and answers `409 error.federation.member_not_on_roster`. The read path already caught it, so nothing was exposed; what it stops is a server that keeps minting successors for a membership that has ended, writing a store row for each token that can never be used. `error.federation.capability_expired` was unused; it is used now, and its catalog context says what both of its statuses mean.
… re-verified Review finding H2. `moderation/mod.rs` said the signature is kept "so an operator can re-verify it long after the fact", and it could not be: verification ran over a claim built from partly-normalized inputs, and neither that claim nor its bytes were stored. `reporting_server` became the canonical `PeerId` form — case-folded, trailing dot stripped — and `reported_at` became a parsed `jiff::Timestamp` rather than the RFC 3339 string that was signed, so rebuilding a claim from the row produced different bytes and a signature that no longer verified. `FederatedReport` now carries `signed`, the exact canonical-CBOR bytes the signature covers, and every other field is derived from that claim rather than assembled beside it. `federation::verify_signed_report` is the operator's re-verification: two byte strings and the peer's key, nothing normalized, and deliberately not a method on `ReportClaim`, because reconstructing a claim from a stored row is the mistake it exists to make unnecessary. The round-trip test files a report whose origin is sent as `Other.Test.`, asserts the row holds the canonical id and the parsed instant, re-verifies from `signed` + `signature`, checks another key fails, and asserts that bytes rebuilt from the row are *not* equal to the stored ones — without which the case would pass whether or not the fix were real. `design/moderation.md` now states the wire form and the one normalization rule — trim, and nothing else — so a peer implementing from the doc signs the bytes this verifies rather than guessing which fields are folded.
…eport intake Review findings M1 and M2, and what M2's second half turned out to be. **M1.** `reported_user` was a string the peer chose: never required to name an account here, never canonicalized, never length-capped, and then used as a counter key — so a pinned peer cycling `"a"`, `"b"`, `"A"`, `"a "` minted itself a fresh per-account budget each time. Now every field is length-capped before any store is read (`report_bounds`, generous against a DNS name, a UUID and a SHA-256 digest), `reported_user` must resolve to an account this server hosts — otherwise these operators cannot act on it and the row would sit in the queue forever — and `CounterKey::PeerReports` is a per-peer ceiling that ignores the account, which is the only key that bounds a peer's total volume. The `404` is reachable only after the signature verified, so it discloses account existence to a peer whose key an operator pinned and to nobody else. `InMemoryModeration` was an unbounded map with no eviction — process memory that never returns for a `--memory` deployment — and now caps at ten thousand reports, evicting oldest-first with a `warn`, because a dropped report is a moderation input nobody will ever see. **M2, limiter half — landed.** `CounterKey::FederatedIntake` is charged in the handler *before* the peer-store read and the Ed25519 verification, which is where the ordering requirement actually was. Its key is the claimed origin and therefore attacker-chosen; that is stated in the key's own docs rather than papered over, because this server has no trusted client address to key on instead — the same missing fact `RegistrationSource` waits on. **M2, body-cap half — cannot be expressed, filed as #478.** Mounting a second `BodySize` on a federation group is refused by const evaluation: "two interceptors covering this route answer with the same status; a consumer could not tell which one replied". Both produce `413`. Moving `BodySize` off the router would take `413` off the ten gate-exempt operations, which `tests/conformance.rs` pins as declared on every operation — `S-C33`'s contract, not this lane's. `MAX_FEDERATION_BODY_BYTES` is declared, documented as **not enforced**, and carries the error verbatim so the next person finds the constraint instead of rediscovering it.
…reached yet Review finding M3. `SLICES.md` was honest about this and the two design docs were not: `moderation.md` said both federated halves "ship" and described the blocklist as consulted at four boundaries, and `federation.md`'s status note said they were in. Both are built and neither is reachable, for one shared reason — peer keys are operator-pinned and nothing can pin one, because `boot::assemble` refuses the durable backend until #403 and a command running against `--memory` would forget what it pinned. Corrected where a reader actually hits the claim: the blocklist bullet itself, not only the status note, and the same fact in `federation.md`. Both name #403 for the cause and #476 for the command that clears it. That document's own rule is that a blocklist nothing consults reads as protection. One nothing can write reads the same, so the route's own module docs now open by saying its only reachable answer today is `403 error.federation.peer_unknown`. It stays mounted: a peer implementing against the published contract needs the operation to exist and answer honestly, and what is missing is the command, not the surface.
…t a scope hides Review findings L5 and Q6. **L5.** `BlobRole::Backup` answered `403 error.federation.scope_insufficient`. That `403`'s whole justification is that the feed already told the peer the asset is there, so a `404` would send it hunting an address that exists — true of an original under a derivative-only grant, and false of a backup, which the same module calls the owner's durability artefact rather than part of what was shared and which the feed never names. A peer holds no fact about it, so it now gets what a stranger gets. **Q6, decided: the feed is not filtered by scope, and `federation.md` says why.** A derivative-only peer receives every blob's role, hash and size and is refused at the fetch. Filtering the entry's `blobs` array would be theatre: every entry also carries the asset's signed manifest as the exact bytes the client uploaded, its `ciphertext_hash` *is* the original's content address, the peer needs that manifest to verify anything, and the server cannot rewrite it — a re-serialized manifest is detached from its signatures. The address would still be present, unfilterable, two fields away, and the feed would no longer agree with the manifest beside it. So the doc states the boundary plainly instead: a scope decides which bytes are served, never which identifiers are learned; a derivative-only grant does not promise that the peer cannot learn an original exists, its address or its size; and anyone needing that wants a separate album, because the confidentiality boundary is the MLS album key and always was.
…he codes out Review findings L2, L3 and L4. **L2 — and it caught a real one.** The federation conformance suite fixed `granted_epoch` at 3 everywhere, which is precisely the divergence class it exists to catch and could not: the port says `u64`, the durable column is a `BIGINT`, and an epoch above `i64::MAX` is representable to a caller but not to one adapter — the shape #458 shipped. The new case asserts the widest accepted value round-trips unchanged, that zero is a legitimate epoch, and that one past the boundary is **refused rather than narrowed**, because a grant recorded under a different epoch than the one asked for admits the wrong membership. It failed on first run against the in-memory adapter, which accepted what Postgres would have refused. The check now lives in `store::admissible`, shared by every adapter, so the two cannot draw the line in different places. **L3.** `capsule-sdk/src/federation.rs` set `code: String::new()` on the blob and revocation-list refusals while its own docs promised `error.federation.scope_insufficient`, so a peer had to string-match prose. `FetchError::AuthorizationChanged` now carries the code — the `403`s on that route stopped saying one thing when the capability arm landed, and an account's `error.blob.access_revoked` ("re-sync membership") and a peer's `error.federation.scope_insufficient` ("the grant never covered this") call for opposite actions. **L4.** `unwrap_or(u64::MAX)` on `max_staleness_seconds` became `unwrap_or(0)`. A bound this client cannot read now means the snapshot is stale immediately and the next `admit` re-polls, which is the module's documented fail-closed rule; the old arm would have read as fail-open the day the schema widened.
…ounds nothing The report route's own docs flow into `openapi.json`, and the status paragraph that landed with the M3 correction had not been regenerated. While there: the route claimed the cost of verifying before charging was "one Ed25519 verification per unsigned request, which the router's body-size limit already bounds". That limit is 32 MiB and bounds nothing useful, which is what the review said. Replaced with what actually runs — every field capped first, then a claimed-origin intake budget before the store read and the verify, then the policy budgets after it — and with what is still unbounded, bytes parsed per request, pointing at #478.
…surface Review finding 34. The codec unit proves a capability is unreadable to the session verifier and a session token to the capability codec — token confusion. Nothing proved the *routing*: that only three operations mount `ReadBearer` and every other secured operation still takes `Auth<AccessToken>`, so a capability presented on a write is refused by the scheme rather than admitted as some default principal. Asserted on the wire across six operations — album provisioning, roster publish, capability mint, upload creation, quota and the moderation record — using the album the capability is actually for, so nothing passes merely by naming the wrong one. The same token is then admitted on the read it is for, which is what makes the six a test of the surface rather than of a token that had gone bad. Also: `federation::on_peer_blocked` now says in its own docs that it is owed a caller, the way `boot` names what #403 owes. The refusal a block causes is automatic — `blocked_at` is consulted at four boundaries — and the cascade is not; it adds publication, which is why it is a separate step, and a reader should not assume it runs by itself.
…server-federation-406
…one route Review finding 35, and the commit that introduced the ceiling said the wrong thing about it. `store::admissible` bounded a token against `MAX_TOKEN_TTL` and against its own grant's deadline, but nothing anywhere bounded that deadline: the ninety-day cap lived only in `mint_capability`'s parsing of `renewable_until`. Unreachable today, because the route is the only caller of `issue()` — and the stated goal was "shared by every adapter, so an adapter cannot re-derive the check differently", which a route-side check is not. past it. `MAX_GRANT_LIFETIME` moves to `federation::store` beside the rules it belongs with, and `admissible` refuses a record whose `not_after` is further from its `issued_at` than the ceiling allows. The route keeps its own `400`, which is now a better diagnosis of the same refusal rather than the only thing enforcing it. The conformance case issues **directly through the port**, bypassing the route entirely, because that is the property under test. It also pins the boundary as inclusive — exactly ninety days is admissible, a mistyped year is not — and that a refresh cannot walk past the ceiling, since the successor carries the deadline unchanged and the cap is therefore fixed at the original mint rather than re-measured per token. Neither of the two cases beside it covered this: one bounds a token's life, the other a successor against its predecessor.
…l that it is unknown Review finding 36. Requiring `reported_user` to resolve was right — an unresolvable report is a permanent orphan row nobody can act on, which was M1's actual point — but answering the failure with a distinct coded `404` was not. It manufactured an account-enumeration oracle out of a check that never needed one: a peer could walk identifiers and read existence off the status line. The earlier defence was that it is bounded to operator-pinned peers. That is accurate and insufficient. A pinned key can be compromised, and a peer can be adversarial toward its own users while remaining an operator's legitimate partner; "trusted enough to report" is not "trusted with enumeration". This codebase treats exists-versus-does-not as a first-order defect nearly everywhere else — `enroll.rs`'s indistinguishable code refusal, the album ceremonies' "not yours is not found", the blob authority's `404`/`403` boundary — and this route was the exception. Intake now answers `202 Accepted` either way. A resolvable `reported_user` is filed; an unresolvable one is dropped with a `tracing::warn!`, which is the "log so an operator can act, never tell the asker" pattern `enroll.rs` already uses. The queue stays clean and no oracle appears. Probing is not free: every budget is charged before the account is looked at, so a peer sweeping identifiers spends its allowance and the operator sees the warnings. The test asserts the two answers have the same status *and* the same body shape, differing only in the fresh `report_id` every acceptance carries — a body that were empty or short a field would be the oracle back again one field along — and that only the resolvable report reached the store. `error.moderation.report_unknown_user` had no other user and is removed from the catalog; the `404` leaves the contract with it.
justin13888
added a commit
that referenced
this pull request
Sep 10, 2026
The conflicts are genuine federation-versus-everything-else interactions: - counter/mod.rs: #472 added four federation CounterKey variants and their budgets against a base that had neither ceiling() (#403's partition work) nor scope() (#403's Valkey key). Both matches were non-exhaustive. Added the arms, and four ceilings the module was missing: PEER_ORIGIN for the two peer-keyed variants, and FEDERATED_REPORTS/FEDERATED_INTAKE sized from #472's own budgets — the intake key is charged before authentication, so its ceiling is the only bound on an attacker-chosen key space and it is sized like the other caller-supplied partitions. - boot.rs: ServerInfo gained a conditional builder call on each side. Both apply; with_oidc and with_federation compose before the Arc. - tests/support/mod.rs: Fixture::build grew a parameter on each side independently — (provider, counter_ceiling) from #407/#432 and federation_url from #472. One signature carrying all four, and every constructor updated. - tests/sdk_client.rs: both sides appended a test at the same point; union. capsule-server/openapi.json regenerated with mise run openapi-kynos for the federation routes.
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
capsule-server::federation(slicesS-E2,S-E5,S-C49): the capability token a peer server presents on the existingGET /v1/sync?album_id=andGET /v1/blob/{hash}reads, the store it is issued from and revoked into (which is now the revocation list/.well-known/capsule/revoked-jtiserves), the peers this server pins and blocks, and the lifecycle around it. Stacked on #458 (feat/server-album-membership-405); the PR targets that branch.Status: complete and ready for review. Delivered as eight ordered commits, each leaving the tree coherent. No human has approved the plan below — it was executed under the run's unattended declaration and is published here for review, decision by decision.
Related Issues
Refs #406 — the serving half of
S-E2, all ofS-E5, and both ofS-C49's blocked deliverables.Filed and linked: #478 — server: no operation can carry a body cap tighter than the router-wide backstop — Kynos refuses a second
BodySizeon a route, which is decision 30's undeliverable half.Filed and linked: #476 — server: federation's receiving half — the egress pull worker, re-validation, weighted per-peer budgets, and the operator peer commands. It carries everything this PR names as owed, with the reason each was left rather than stubbed and the seam each would attach to.
Contributor Checklist
Summary
capsule-server::federationon Kynos. Eight commits built it; seven more answered the review of436c4f80; one merge brought the base's terminal head in.Built (
20c4d81e…436c4f80) — the EdDSA-JWT capability and the two ports behind it, thedual-principal
bearerscheme, the capability arm onGET /v1/sync?album_id=andGET /v1/blob/{hash}, mint/revoke/refresh, signed report intake and the blocklist, Postgresordinal 6,
capsule_sdk::federation::FederationPull, and the docs.Answered from review (
e0ad6307…bb4c86ef), findings 26–34:federation/store.rs:71,106,127,163,routes/federation.rs:107,183,466,653granted_epoch→409routes/federation.rs:676moderation/mod.rs:141,187,federation/report.rs:96,routes/federation.rs:1086reported_usermust resolve to an account here; per-peer ceiling; the in-memory queue is cappedroutes/federation.rs:836,1049,1166,moderation/mod.rs:196,counter/budgets.rs:83routes/federation.rs:1144,counter/mod.rs:95,limits.rs:68moderation.md:35,61,federation.md,routes/federation.rs:186404, not403; the feed is not filtered by scope, andfederation.mdsays whyserve/authority.rs:397,federation.mdfederation/conformance.rs:487,federation/store.rs:135,sdk/fetch.rs:224,sdk/federation.rs:189,265on_peer_blockedsays it is owed a callertests/federation.rs:1268,federation/mod.rs:437Answered from the confirming review (
74ac8035,b8babdb3), findings 35–36:federation/store.rs:134,167,federation/conformance.rs:487202whether or notreported_userresolves — the distinct404was an account-enumeration surface; unresolvable reports are dropped with awarnand never filedroutes/federation.rs:220,1150,moderation.md:29Two decisions the review asked me to name.
26 — I took the absolute deadline and made renewability explicit rather than defaulted.
MintCapabilityRequest.renewable_untilis absent by default, which meansnot_after == expires_atand the first refresh is403 error.federation.capability_expired; present, it mustbe future and within ninety days, and each successor is minted for
min(DEFAULT_TTL, not_after − now), so the last token of a grant ends at the deadline. The mint response statesnot_afterand a plain
renewableflag. The rule is enforced instore::continues/store::admissible,shared by every adapter, so it is structural rather than the route's.
32 — I chose not to filter
render()'sblobsby scope, and stated why infederation.md.Filtering would be theatre: every entry also carries the asset's signed manifest as the exact
bytes the client uploaded, its
ciphertext_hashis the original's content address, a peerneeds that manifest to verify anything, and the server cannot rewrite it without detaching it
from its signatures. The address would remain, unfilterable, two fields away, and the feed would
stop agreeing with the manifest beside it. So the doc states the boundary instead: a scope
decides which bytes are served, never which identifiers are learned.
SLICES.mdrow delta: 0. Only theS-E2/S-E5rows and theS-E2/S-E5/S-C49detailblocks changed.
Validation
Re-run in full at head
b8babdb3, after merging the base's terminal head, each command in theforeground with
CARGO_TARGET_DIR=/var/tmp/capsule-lane-406/target(exceptgen-bindings, runwith it unset).
Focused — and the integration binaries are not the whole of it
The three
--testbinaries below miss the adapter-parity suite entirely: the in-memory/Postgresconformance cases live in
src/federation/{memory,postgres}.rs's#[cfg(test)] mod tests, sothey need
--lib. That is where decision 33's epoch boundary and decision 35's lifetimeceiling are pinned, and where 33's failure was found. Run all four.
cargo test -p capsule-server --test federationcargo test -p capsule-server --test conformancecargo test -p capsule-server --test sdk_clientcargo test -p capsule-server --lib federation::check-rust, sub-task by sub-task — all OK:format-check-rust,lint-check-rust,doc-check-rust,i18n-check,i18n-guard,openapi-check-kynos,architecture-check,license-check,translate-readme-check,build-rust,build-check-wasm,build-ffi,lint-check-ffi,gen-bindings,verify-examples.Suites
mise run test-rustcargo test -p capsule-server --lib postgres_conformance -- --test-threads=1federation::postgres::tests::…::the_postgres_federation_stores_conform, ordinal 6 up and down, and the boot compose checkcheck-docs-truth/check-md/check-docsFailures observed and classified
5fcd4a6cmerged clean, no conflicts,openapi.jsonneeded nohand-resolution and
openapi-check-kynosconfirms the committed document is byte-identical towhat the router emits. Every gate above ran post-merge.
the in-memory adapter, which accepted a
granted_epochabovei64::MAXthat Postgres wouldrefuse — the same divergence class [FEAT] Server-side album membership (S-C51) #458 shipped, in my own code. The check moved into the
shared
store::admissible.404exercise forPOST /v1/federation/reportshad to go, because the status no longer exists. The response setshrank by one and
openapi.jsonwas regenerated;openapi-check-kynosconfirms it.BodySizeon the federation group is refused by constevaluation — "two interceptors covering this route answer with the same status; a consumer
could not tell which one replied". Both produce
413. Filed as server: no operation can carry a body cap tighter than the router-wide backstop — Kynos refuses a second BodySize on a route #478; see Decisions taken 30.a container at once, several getting
client error (SendRequest). Serial is 10/10, repeatedly.lint-check-rustomits--all-targets, so no test target has everbeen linted. Run with it, my files are clean;
capsule-server/tests/ops.rs:115,tests/support/mod.rs:482,2504,tests/sync.rs:296,src/counter/tests.rs:216-217,src/gc/tests.rs:299andcapsule-sdk/src/auth.rs:1402are not mine and are build: lint-check-rust omits --all-targets, so clippy has never run over any test target #474's to clear.Risks and rollout
Base: merged and terminal.
feat/server-album-membership-405at5fcd4a6cis merged bymerge commit
ae74ff58. Never rebased. The base's own fix —sign_lifecyclere-mintingcreated_by_user/created_by_devicefrom the acting account and signing device — touchescapsule-coreand the ops surface, neither of which this branch edits; nothing conflicted.Contract changes a reviewer should weigh
CapabilityRecordgainednot_after, and migration ordinal 6 gained the column. The ordinal isunreleased, so it was amended rather than followed by a seventh.
MintCapabilityRequestgainedrenewable_until; the mint and refresh responses gainednot_after, and mint gainedrenewable. Refresh gained a409.FetchError::AuthorizationChangedbecame a struct variant carrying the stable code — the403son the blob route stopped saying one thing when the capability arm landed.
ReadAuthority::blob_read_accesstakes aReadPrincipal;BlobReadAccessgainedScopeInsufficient;CursorScopebecame an enum with the wire format and version byteunchanged, pinned by two literal cursors minted under the old form.
ModerationStoregainedfile_report/pending_reports;FederatedReportcarriessigned.POST /v1/federation/reportslost its404: the response set is now202/400/401/403/413/415/422/429/500.error.moderation.report_unknown_useris removed from the catalog, having no other user.MAX_GRANT_LIFETIMEmoved fromroutes::federationtofederation::storeand is enforced bystore::admissible, so any future caller ofissue()— server: federation's receiving half — the egress pull worker, re-validation, weighted per-peer budgets, and the operator peer commands #476's operator tooling — inherits it.CounterKeygainedPeerRequests,FederatedReports,PeerReports,FederatedIntake.Rollout
FEDERATION_URLunset is a deployment that does not federate: nofederation_urlonserver-info, mint/refresh/intake403 error.federation.not_configured, and revoke stillworks.
and nothing can pin one until server: Valkey adapters for the auth-state and upload-session ports #403's durable boot arm, then server: federation's receiving half — the egress pull worker, re-validation, weighted per-peer budgets, and the operator peer commands #476's command. Intake answers
403 error.federation.peer_unknownto every peer today. Both design docs and the route's owndocs say so where a reader meets the claim.
downis exercised, andEXPECTED_MIGRATIONSgained the row.Decisions taken
The plan's record, verbatim, followed by the decisions this run took while delivering it.
The peer blob tests live in tests/federation.rs, not tests/blob.rs
Taken: every peer-facing blob case sits beside the capability helpers that mint and record the
grants they need; tests/blob.rs stays the account surface's file and is untouched.
Rejected: the plan's validation-table placement (it would have duplicated the minting fixture).
Reverses: the plan's "where" column for scenarios 5, 6 and 13.
A peer outside its capability's album is 404 on the blob route and 403 on the feed
Taken: the blob route answers a stranger's 404, byte-identical to an unknown address, because a
403 would confirm the address is referenced by somebody; the feed answers 403
error.federation.audience_mismatch, because the peer named an album_id rather than an
opaque address and learns nothing from being told its own grant does not cover it.
Rejected: one status for both (the two surfaces disclose different things).
Reverses: -
A peer's grant that outlived its membership is Revoked at the blob authority, not Unrelated
Taken: a member removed, or re-admitted at a later epoch, is BlobReadAccess::Revoked -> 403
error.federation.capability_revoked; the peer held the grant, so the change is a
disclosure it is owed, and it is the same 403 a former account member gets.
Rejected: 404 (it would leave a peer polling an album it can no longer read, with no signal).
Reverses: -
A grant carries an absolute deadline, and renewability is asked for rather than assumed
Taken: CapabilityRecord.not_after, fixed at the original mint and copied unchanged into
every successor; MintCapabilityRequest.renewable_until, absent by default, which
makes not_after == expires_at and the first refresh 403 capability_expired. A
successor is minted for min(DEFAULT_TTL, not_after - now). store::continues refuses
a successor whose deadline moved and store::admissible a token that outruns it, so
the rule is the store's and not one route's.
Rejected: bounding the chain by depth (a peer refreshing every 23h is indistinguishable from
one refreshing every hour, and only the wall clock is the thing the owner meant);
defaulting to a renewal window (H1 is precisely that a default nobody chose becomes
an indefinite grant).
Reverses: the plan's implicit "ttl_seconds is the grant's lifetime" — it is one token's.
A refresh re-asks the roster
Taken: 409 error.federation.member_not_on_roster when the member has left or the epoch
moved. The read path already refuses such a token, so nothing was exposed; what
stops is a server minting successors that can never be used, one store row each.
Rejected: leaving it to the read path (a contract that issues unusable tokens is a confusing
one, and the row is real).
Reverses: -
The signed bytes are what is stored
Taken: FederatedReport.signed holds the exact canonical CBOR the signature covers; every
other field is derived from that claim; federation::verify_signed_report is the
operator's re-verification and takes bytes and a key, nothing normalized.
Rejected: rebuilding a claim from the row (the row is normalized — PeerId folding and a
parsed instant — so it produces different bytes and an unattributable report);
storing the raw JSON body (the signature covers CBOR, not the body).
Reverses: the claim at moderation/mod.rs:141 that the signature alone made a row
re-verifiable.
Everything a peer chooses is bounded, and the account must be ours
Taken: every field length-capped before any store read; reported_user must resolve to an
account this server hosts (404 error.moderation.report_unknown_user, reachable only
after the signature verifies); CounterKey::PeerReports as a ceiling that ignores the
account; InMemoryModeration capped at 10 000 with oldest-first eviction and a warn.
Rejected: canonicalizing reported_user instead of resolving it (canonical nonsense is still
nonsense as a counter key); silent eviction (a dropped report is a moderation input
nobody will ever see).
Reverses: -
The intake limiter is in the handler; the body cap cannot be expressed
Taken: CounterKey::FederatedIntake charged before the peer-store read and the Ed25519
verify. The ordering requirement was "before the peer lookup", not "in an
interceptor", and the handler is ours. Its key is the claimed origin and therefore
attacker-chosen, which is stated in the key's own docs: it bounds one origin
looping, not a caller cycling origins, and this server has no trusted client
address to key on instead.
Rejected: an interceptor. Kynos refuses a second BodySize covering a route, by const
evaluation: "two interceptors covering this route answer with the same status; a
consumer could not tell which one replied". Both produce 413. Moving BodySize off
the router would strip 413 from the ten gate-exempt operations, which
tests/conformance.rs pins as declared on every operation — S-C33's contract.
Filed: server: no operation can carry a body cap tighter than the router-wide backstop — Kynos refuses a second BodySize on a route #478, with the error verbatim and the three ways out.
MAX_FEDERATION_BODY_BYTES is declared, documented as NOT enforced, and links it.
The docs say what is reachable, not what is built
Taken: moderation.md's blocklist bullet and status note, federation.md's status note, and
the route's own module docs all say intake and the blocklist cannot be reached
until server: Valkey adapters for the auth-state and upload-session ports #403 then server: federation's receiving half — the egress pull worker, re-validation, weighted per-peer budgets, and the operator peer commands #476, and that intake's only answer today is 403 peer_unknown.
Rejected: unmounting the route (a peer needs the published contract; what is missing is the
operator command, not the surface).
Reverses: the earlier status notes that said both halves "ship".
A backup is a stranger's blob, and the feed is not filtered by scope
Taken: BlobRole::Backup answers Unrelated (404): the 403's premise is that the feed already
named the asset, and the feed never names a backup. The blobs array is NOT filtered
by the presenting scope, and federation.md states why: every entry carries the
signed manifest verbatim, its ciphertext_hash IS the original's address, the peer
needs it to verify anything, and the server cannot rewrite it without detaching it
from its signatures — so filtering would hide an address still present two fields
away while breaking the feed's agreement with the manifest.
Rejected: filtering (theatre, and it costs consistency); saying nothing (the review's point).
Reverses: the earlier treatment of a backup as a scope failure.
The conformance suite pins the boundary, not a convenient value
Taken: the widest granted_epoch every adapter accepts, zero, and the first refused — and
the refusal is a rejection, never a narrowing. It failed on first run: the in-memory
adapter accepted what Postgres refuses, so the check moved into store::admissible.
FetchError::AuthorizationChanged carries the stable code; max_staleness_seconds
falls back to 0, not u64::MAX.
Rejected: asserting the epoch only through Postgres (the point is that the two agree).
Reverses: -
The routing is pinned, not only the codec
Taken: six account-only operations are asserted to refuse a capability on the wire, using
the album the capability is for, with the same token then admitted on its own read.
federation::on_peer_blocked says in its docs that it is owed a caller.
Rejected: trusting that Auth is mounted where it looks mounted.
Reverses: -
Unresolved review notes
nbfis honoured with zero leeway on the issuer's own clock; the verifier in v1 is always the issuer, so the skew tolerance the table givesnbfis nil here. Recorded, not changed.Peer keys are operator-pinned and nothing can pin one yet (decision 24).
POST /v1/federation/reportstherefore answers403 error.federation.peer_unknownto every real peer until server: Valkey adapters for the auth-state and upload-session ports #403's durable boot arm and the operator command land. That is the honest state, not a gap this PR can close — but a reviewer should confirm they agree the intake route is worth shipping ahead of the command that makes it usable.The peer blob tests live in
tests/federation.rsrather thantests/blob.rs(decision 18);tests/blob.rsis untouched. A reviewer who expects the blob surface's cases in one file should say so.federation::on_peer_blockedhas no production caller: the blocklist is consulted at every boundary regardless, and the cascade exists so a block is legible on the published list. It is driven by a test, and by the operator command when that lands.capsule-server/src/federation/report.rs::the_signing_bytes_are_stablepins the canonical-CBOR encoding as a hex literal. That is deliberate — it is the contract every peer signs against — but it means any rename of aReportClaimfield fails there first, which is the intent and should not be "fixed" by regenerating the literal without thinking about the peers.error.federation.circuit_openanderror.federation.capability_expiredremain in the catalog and unused: the first until server: federation's receiving half — the egress pull worker, re-validation, weighted per-peer budgets, and the operator peer commands #476's weighted counter, the second because the framework's401carries no code.Decision 30's body-cap half did not land and is server: no operation can carry a body cap tighter than the router-wide backstop — Kynos refuses a second BodySize on a route #478.
POST /v1/federation/reportsstill parses up to the 32 MiB router backstop before any bound applies; what does apply is per-field length caps and a claimed-origin budget, both before the store read and the verify. A reviewer who considers bytes-parsed the load-bearing half should say so.The intake budget's key is attacker-chosen (the claimed
reporting_server). It bounds one origin looping and not a caller cycling origins. There is no better key until this server has a trusted client address — the same factRegistrationSource,ShareSourceandDropSourceall wait on.Closed by finding 36: intake answerserror.moderation.report_unknown_usermakes intake an account-existence oracle for a pinned peer.202either way, files only what resolves, and logs the rest for the operator. The code is gone from the catalog. What remains true, and is the residual: an operator reading the queue cannot tell a misrouted report from a probe without reading the logs beside it.MAX_GRANT_LIFETIMEis ninety days, and as of finding 35 it is enforced in the store rather than the route. Not a security boundary — the owner chose the date and can revoke — but it is a number nobody has ratified.The epoch fix means a
granted_epochabovei64::MAXis now refused at issue. Nothing mints one (epochs come from a roster), so this is a guard, not a behaviour change.