Skip to content

[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
feat/server-album-membership-405from
feat/server-federation-406
Open

justin13888 wants to merge 22 commits into
feat/server-album-membership-405from
feat/server-federation-406

Conversation

@justin13888

@justin13888 justin13888 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Description

capsule-server::federation (slices S-E2, S-E5, S-C49): the capability token a peer server presents on the existing GET /v1/sync?album_id= and GET /v1/blob/{hash} reads, the store it is issued from and revoked into (which is now the revocation list /.well-known/capsule/revoked-jti serves), 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 of S-E5, and both of S-C49's blocked deliverables.

Filed and linked: #478server: no operation can carry a body cap tighter than the router-wide backstop — Kynos refuses a second BodySize on a route, which is decision 30's undeliverable half.

Filed and linked: #476server: 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

  • 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

Summary

capsule-server::federation on Kynos. Eight commits built it; seven more answered the review of
436c4f80; one merge brought the base's terminal head in.

Built (20c4d81e436c4f80) — the EdDSA-JWT capability and the two ports behind it, the
dual-principal bearer scheme, the capability arm on GET /v1/sync?album_id= and
GET /v1/blob/{hash}, mint/revoke/refresh, signed report intake and the blocklist, Postgres
ordinal 6, capsule_sdk::federation::FederationPull, and the docs.

Answered from review (e0ad6307bb4c86ef), findings 26–34:

# What changed Where
26 (H1) A grant carries an absolute deadline a refresh cannot move; renewability is an explicit mint-time property federation/store.rs:71,106,127,163, routes/federation.rs:107,183,466,653
27 Refresh re-checks the roster at granted_epoch409 routes/federation.rs:676
28 (H2) The exact signed bytes are stored, so a filed report re-verifies moderation/mod.rs:141,187, federation/report.rs:96, routes/federation.rs:1086
29 (M1) Every field bounded; reported_user must resolve to an account here; per-peer ceiling; the in-memory queue is capped routes/federation.rs:836,1049,1166, moderation/mod.rs:196, counter/budgets.rs:83
30 (M2) Claimed-origin intake budget charged before the peer read and the verify — in the handler, not an interceptor. Body cap not deliverable, filed as #478 routes/federation.rs:1144, counter/mod.rs:95, limits.rs:68
31 (M3) Both design docs corrected where a reader hits the claim; the route says what its only reachable answer is moderation.md:35,61, federation.md, routes/federation.rs:186
32 (L5+Q6) A backup is 404, not 403; the feed is not filtered by scope, and federation.md says why serve/authority.rs:397, federation.md
33 (L2-L4) Epoch boundary case — which caught a real adapter divergence; codes threaded; fail closed federation/conformance.rs:487, federation/store.rs:135, sdk/fetch.rs:224, sdk/federation.rs:189,265
34 A capability is refused on six account-only surfaces; on_peer_blocked says it is owed a caller tests/federation.rs:1268, federation/mod.rs:437

Answered from the confirming review (74ac8035, b8babdb3), findings 35–36:

# What changed Where
35 The ninety-day grant ceiling moved into the store, where decision 26's commit claimed it already was; conformance issues past it directly through the port, bypassing the route federation/store.rs:134,167, federation/conformance.rs:487
36 Intake answers 202 whether or not reported_user resolves — the distinct 404 was an account-enumeration surface; unresolvable reports are dropped with a warn and never filed routes/federation.rs:220,1150, moderation.md:29

Two decisions the review asked me to name.

26 — I took the absolute deadline and made renewability explicit rather than defaulted.
MintCapabilityRequest.renewable_until is absent by default, which means not_after == expires_at and the first refresh is 403 error.federation.capability_expired; present, it must
be 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 states not_after
and a plain renewable flag. The rule is enforced in store::continues/store::admissible,
shared by every adapter, so it is structural rather than the route's.

32 — I chose not to filter render()'s blobs by scope, and stated why in federation.md.
Filtering 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, a peer
needs 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.md row delta: 0. Only the S-E2/S-E5 rows and the S-E2/S-E5/S-C49 detail
blocks changed.

Validation

Re-run in full at head b8babdb3, after merging the base's terminal head, each command in the
foreground with CARGO_TARGET_DIR=/var/tmp/capsule-lane-406/target (except gen-bindings, run
with it unset).

Focused — and the integration binaries are not the whole of it

The three --test binaries below miss the adapter-parity suite entirely: the in-memory/Postgres
conformance cases live in src/federation/{memory,postgres}.rs's #[cfg(test)] mod tests, so
they need --lib. That is where decision 33's epoch boundary and decision 35's lifetime
ceiling are pinned, and where 33's failure was found. Run all four.

Command Outcome
cargo test -p capsule-server --test federation 35 passed
cargo test -p capsule-server --test conformance 10 passed
cargo test -p capsule-server --test sdk_client 5 passed (E2E case 4 over a socket)
cargo test -p capsule-server --lib federation:: 18 passed — the adapter-parity suite

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

Command Outcome
mise run test-rust 2910 passed (1998 + 740 + 172), 0 failed, 0 skipped
cargo test -p capsule-server --lib postgres_conformance -- --test-threads=1 10 passed, incl. federation::postgres::tests::…::the_postgres_federation_stores_conform, ordinal 6 up and down, and the boot compose check
check-docs-truth / check-md / check-docs all OK

Failures observed and classified

  • The merge produced none. 5fcd4a6c merged clean, no conflicts, openapi.json needed no
    hand-resolution and openapi-check-kynos confirms the committed document is byte-identical to
    what the router emits. Every gate above ran post-merge.
  • Caused, and fixed before commit: the new epoch conformance case failed on first run against
    the in-memory adapter, which accepted a granted_epoch above i64::MAX that Postgres would
    refuse — 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.
  • Caused by finding 36, and expected: the conformance walk's 404 exercise for
    POST /v1/federation/reports had to go, because the status no longer exists. The response set
    shrank by one and openapi.json was regenerated; openapi-check-kynos confirms it.
  • Blocked, not failed: a second BodySize on the 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. 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.
  • Flaky (environment): the Postgres tier fails in parallel — ten tests each asking podman for
    a container at once, several getting client error (SendRequest). Serial is 10/10, repeatedly.
  • Pre-existing (build: lint-check-rust omits --all-targets, so clippy has never run over any test target #474): lint-check-rust omits --all-targets, so no test target has ever
    been 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:299 and capsule-sdk/src/auth.rs:1402 are 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-405 at 5fcd4a6c is merged by
merge commit ae74ff58. Never rebased. The base's own fix — sign_lifecycle re-minting
created_by_user/created_by_device from the acting account and signing device — touches
capsule-core and the ops surface, neither of which this branch edits; nothing conflicted.

Contract changes a reviewer should weigh

  • CapabilityRecord gained not_after, and migration ordinal 6 gained the column. The ordinal is
    unreleased, so it was amended rather than followed by a seventh.
  • MintCapabilityRequest gained renewable_until; the mint and refresh responses gained
    not_after, and mint gained renewable. Refresh gained a 409.
  • FetchError::AuthorizationChanged became a struct variant carrying the stable code — the 403s
    on the blob route stopped saying one thing when the capability arm landed.
  • ReadAuthority::blob_read_access takes a ReadPrincipal; BlobReadAccess gained
    ScopeInsufficient; CursorScope became an enum with the wire format and version byte
    unchanged, pinned by two literal cursors minted under the old form.
  • ModerationStore gained file_report/pending_reports; FederatedReport carries signed.
  • POST /v1/federation/reports lost its 404: the response set is now 202/400/401/403/413/415/422/429/500. error.moderation.report_unknown_user is removed from the catalog, having no other user.
  • MAX_GRANT_LIFETIME moved from routes::federation to federation::store and is enforced by store::admissible, so any future caller of issue()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.
  • CounterKey gained PeerRequests, FederatedReports, PeerReports, FederatedIntake.

Rollout

Decisions taken

The plan's record, verbatim, followed by the decisions this run took while delivering it.

Issue 406 - server: federation — capabilities, the pull path, and the sync capability gate (S-E2, S-E5, S-C49)
Plan:     v1 (this document)
Branch:   feat/server-federation-406
Base:     feat/server-album-membership-405 (head of the W-MEMBER PR #458), stacked; the PR targets that branch
Cause:    -
Touches:  capsule-server/src/federation/**, routes/{federation,sync,blob,roster}.rs, serve/{mod,authority}.rs,
          sync/cursor.rs, counter/{mod,budgets}.rs, moderation/mod.rs, discovery/revocation.rs,
          {app,boot,config,lib,cli}.rs, .env.example, migration/src/{lib,m20260902_000006_federation}.rs,
          postgres/mod.rs, openapi.json, tests/{federation,sync,blob,moderation,well_known,conformance,sdk_client}.rs,
          tests/support/mod.rs, locales/en.json + generated i18n, capsule-sdk/src/{federation,sync,fetch,lib}.rs,
          capsule-docs/planned-modules.txt, design/{federation,moderation,api-surfaces,threat-model/validation,
          import/download-sync,module-map}.md, SLICES.md
Will not: add a pull route (pull IS sync+blob, federation.md:19-25); bytes/CPU budgets, error budget, circuit
          breaker, probation (federation.md:135-138); rejected-hash table / inbound stale-revival (:140-155);
          network peer-key fetch or perspective check (:188-190); blocklist exchange (moderation.md:37);
          admin wire surface or admin auth; per-user blocks; capsule-e2e (W-E2E); negotiation.rs; ServerInfo
          fields beyond reading federation_url; capsule-core::federation (S-E4, already landed)
Lane:     serialised behind #405; forecast collisions: W-OIDC #459 on lib.rs, openapi.json, locales/en.json +
          generated i18n, config.rs, boot.rs, .env.example, app.rs, tests/conformance.rs, tests/support/mod.rs,
          SLICES.md, api-surfaces.md; W-E2E #463 on planned-modules.txt, SLICES.md, module-map.md;
          W-MEMBER's own tail on routes/sync.rs, sync/cursor.rs, serve/authority.rs, tests/sync.rs
Settled:  federation.md:19-25,83 (no new protocol; pull-only); :93-107 (claim set, Ed25519 operational key);
          :111-115 (lifecycle, (peer_id,jti) idempotency, 24h + 15-min list); :100 (scope by blob role);
          :117 (transport control); validation.md:64-68,74,147,161-162; api-surfaces.md:168-169,174-187;
          moderation.md:27-29,35-38,58,61,66; download-sync.md:18,49,51,132; module-map.md:134-135;
          SLICES.md S-E2 (4512-4525), S-E5 (4556-4580), S-C49 (3815-3840)

Decisions taken.

1. Pull is the existing sync and blob surfaces under a capability bearer
   Taken:    GET /v1/sync?album_id= and GET /v1/blob/{hash} gain a capability arm; the only new operations
             are mint, revoke, refresh and report intake.
   Rejected: a dedicated /v1/federation/pull route (federation.md:19-25 forbids a new data protocol).
   Reverses: the charter's "pull route" forecast (routes/federation.rs carries lifecycle, not data).

2. Claim set is federation.md:93-105 verbatim; the epoch binding lives in the store record
   Taken:    CapabilityRecord.granted_epoch (from Membership::Member) is checked at presentation; token has no
             epoch claim.
   Rejected: an extra epoch claim (the token format is normative and parsed by every peer, federation.md:89).
   Reverses: the charter's "bound to ... epoch range" as a token field.

3. One bearer component key, two principals
   Taken:    federation::scheme::ReadBearer with NAME "bearer" and a describe() asserted equal to
             AccessToken's; Principal::{Session, Peer}; authenticator delegates to AuthContext first.
   Rejected: widening AccessToken's credential (touches ~40 handlers); MaybeAuth pairs (present-but-invalid
             refuses before the second scheme runs).
   Fallback: if Kynos refuses the duplicate key, NAME "capability" and the SDK registers both keys.
   Reverses: -

4. Coded refusals are rendered by routes from an admitted credential, never by the authenticator
   Taken:    revoked/audience/scope/budget are route-side 403/429 with error.federation.* codes; structural
             and expiry failures stay the framework's uncoded 401 (scheme.rs:87-94).
   Rejected: coding the 401 (no seam in AuthRejection).
   Reverses: leaves error.federation.capability_expired/capability_invalid unused server-side (as today).

5. Blob boundary for a peer: wrong album or never-member is 404, revoked is 403, originals under
   read-derivative-only are 403 scope_insufficient
   Taken:    ReadAuthority takes ReadPrincipal; BlobReadAccess::ScopeInsufficient added; S-C40's 409 is
             account-only (download-sync.md:132).
   Rejected: a separate peer authority trait (the facts are the same store's).
   Reverses: -

6. Revocation writes: roster removal, peer block, explicit revoke, refresh; not epoch-bump-alone, not takedown
   Taken:    routes/roster.rs Applied → federation::on_roster_applied; PeerStore::block cascades; takedown stays
             a serving-hold 410 (moderation.md:58,66).
   Rejected: revoking on every epoch bump (members keep their keys; nothing to cut).
   Reverses: the charter's "takedown" and "epoch bump" as revocation triggers.

7. The capability store is the revocation list
   Taken:    CapabilityStore adapters implement RevocationList; Postgres keeps federation_revoked_jti beside
             federation_capabilities and writes both in one transaction; InMemoryRevocations retired in slice 4.
   Rejected: two sources of truth for "is this jti revoked".
   Reverses: -

8. Budgets through CounterStore only: events/hour per peer, reports/hour per (server,user)
   Taken:    CounterKey::PeerRequests, CounterKey::FederatedReports; budgets in counter/budgets.rs.
   Rejected: bytes/CPU budgets, breaker, probation (need a weighted counter → post-v1, circuit_open key idle).
   Reverses: -

9. Peer identity is operator-pinned; blocklist is a column on the peer row
   Taken:    PeerStore {pin, block, unblock, read}; no outbound HTTP; operator subcommands in a droppable slice.
   Rejected: TOFU fetch of .well-known/capsule/server-info at intake (server has no HTTP client).
   Reverses: the charter's "how the granting server knows the peer (server-info keys)" — the granting server
             needs no peer key to mint; the pinned key serves signed reports.

10. Report intake is a signed, unauthenticated write; the queue is the port
   Taken:    POST /v1/federation/reports; ModerationStore::file_report/pending_reports; no admin surface.
   Rejected: an admin read route (no admin auth exists; SLICES.md:3830-3834).
   Reverses: -

11. Cursor scope gets a third byte for peers
   Taken:    CursorScope::peer(peer_id, album), MAC input byte 2.
   Rejected: reusing CursorScope::album with the peer id as caller (structural separation is one byte).
   Reverses: -

12. SDK orchestration over generated calls only
   Taken:    federation::FederationPull; SyncConsumer::pull_album; CapabilityBlobSource over rest get_blob;
             revoked-jti poll with the 15-min fail-closed rule.
   Rejected: reusing HttpBlobSource (raw reqwest over a Session).
   Reverses: -

13. Federation is opt-in by FEDERATION_URL
   Taken:    config.federation_url → ServerInfo::with_federation; writes refuse 403 error.federation.not_configured
             when unset (mirrors #459's oidc_not_configured).
   Rejected: conditional route mounting (router type is static, lib.rs:257-261).
   Reverses: -
14. Existing moderation catalog keys are reused for the peer-facing refusals
    Taken:    error.moderation.server_blocked (blocked peer at mint / presentation / refresh / intake),
              error.moderation.report_unsigned (bad report signature), error.moderation.report_rate_limited
              (per-(server,user) report budget). New keys: error.federation.not_configured, peer_unknown,
              member_not_on_roster, unavailable.
    Rejected: the plan's error.federation.peer_blocked / report_signature_invalid — duplicates of keys the
              catalog already carries for exactly these refusals (locales/en.json, slice S-C8).
    Reverses: the plan's D8 key list; a client switching on the moderation codes already localizes them.

15. InMemoryRevocations retired with slice 1, not slice 4
    Taken:    boot and the fixture hand one InMemoryCapabilities to discovery and federation from the
              first commit; the four adapter cases moved into federation::conformance unchanged in
              substance; check_revocation and its cases stay in discovery.
    Rejected: two revocation objects for three slices (the fixture would have needed a second rewire).
    Reverses: nothing observable; /.well-known/capsule/revoked-jti tests pass unchanged.

16. The codec is stricter than the plan's claim list
    Taken:    jti must be a UUIDv7, the aud suffix a UUID, min_protocol_version a YYYY-MM-DD date, no
              unknown claims (deny_unknown_fields); a non-positive TTL is clamped to one second rather
              than signed unreadable.
    Rejected: presence-only checks (a token this server never minted would verify under its own key).
    Reverses: nothing a conforming peer sees; federation.md's table names these types already.

17. The store refuses what the route would otherwise have to
    Taken:    issue/refresh reject a record past the 24 h ceiling and a successor naming another peer,
              album or member; a list-side revoke of an issued jti publishes under the record's own
              expiry and never shortens an entry.
    Rejected: trusting every caller of the port to have come through the codec.
    Reverses: -

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

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

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


21. error.federation.capability_invalid carries the refresh's 403 as well as the pull's 401
    Taken:    a session access token presented on POST /v1/federation/capabilities/refresh is 403
              error.federation.capability_invalid — an account has nothing to continue there — and
              the catalog entry's context was widened to name both uses.
    Rejected: a new key (the message is exactly right for both), and a third security scheme just
              to make the refusal structural.
    Reverses: decision 4's "leaves capability_invalid unused server-side" — it is used now.

22. Admission is parameterised by what the capability is being presented for
    Taken:    federation::admit takes a Presentation::{Read, Refresh}. A predecessor revoked *by its
              own refresh* is admissible for Refresh only, because that is exactly what a replayed
              refresh looks like and refusing it would make the promised (peer, jti) idempotency
              unreachable; the successor's own liveness is then re-checked before it is re-signed.
    Rejected: one admit for both (found by a failing test, not by review); a nullable "revoked
              because refreshed" state distinct from revoked_at (two ways to be revoked).
    Reverses: -

23. Three new catalog keys the plan did not forecast, and no federation_reports table
    Taken:    error.federation.album_not_found (404 on the two owner-scoped capability routes),
              error.federation.capability_malformed (400), error.moderation.report_malformed (400).
              The migration adds federation_capabilities, federation_revoked_jti and
              federation_peers and **not** federation_reports: there is no Postgres moderation
              adapter, and this migration crate's own rule is that a migration never describes a
              table nothing reads.
    Rejected: reusing error.album.roster_not_found (its context names one route, which would go
              stale); folding a malformed body into the signature refusal (dishonest).
    Reverses: the plan's D9 table list.

24. The operator peer commands are not droppable, they are not yet writable
    Taken:    capsule-server peer pin|block|unblock is **not** in this PR. boot::assemble and
              boot::assemble_maintenance both refuse the Durable backend outright until #403's
              adapters land, so the command could only run under `serve --memory`, which forgets
              what it pinned the moment it exits. Filed with the rest of #476, to land with or
              after the durable boot arm.
    Rejected: shipping it against --memory (an operator would pin a key and lose it); Demands::Serve
              plus an App::federation() accessor (same problem, plus key material for no reason).
    Reverses: decision 9's "operator subcommands in a droppable slice" — the reason is stronger
              than droppable.

25. Fixture seams added rather than worked around
    Taken:    SwitchableRevocations::set_writes_unavailable (reads answer, writes refuse) and
              Fixture::without_federation. The first is the only seam a route-level coded 500 is
              reachable through on the refresh path — a store that cannot be *read* is refused in
              the authenticator, which can render only 401 — and the second is what makes the
              not_configured refusals wire-observable rather than asserted at the context.
    Rejected: declaring statuses the walk cannot reach (the S-C28 rule), and asserting them at the
              unit while the document promises them on the wire.
    Reverses: -

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

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

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

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

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

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

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

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

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


35. The grant-lifetime ceiling is the store's rule, not one route's
    Taken:    MAX_GRANT_LIFETIME moves to federation::store and store::admissible refuses a
              record whose not_after is further from its issued_at than the ceiling allows. The
              conformance case issues past it *directly through the port*, bypassing the route,
              and pins the boundary as inclusive; it also pins that a refresh cannot walk past
              the ceiling, since a successor carries the deadline unchanged.
    Rejected: leaving it in mint_capability's parsing of renewable_until. Unreachable today
              because that route is the only caller of issue() — and decision 26's own commit
              claimed the rule was "shared by every adapter, so an adapter cannot re-derive the
              check differently", which a route-side check is not. #476's operator tooling is
              exactly the second caller that would have bypassed it.
    Reverses: the placement decision 26 shipped, not its substance.

36. Intake answers 202 whether or not the account exists
    Taken:    a report naming an account this server does not host is accepted on the wire and
              dropped with a tracing::warn!, filed nowhere. Requiring reported_user to resolve
              stays — an unresolvable report is a permanent orphan row, which was M1's point —
              but the *answer* no longer varies with the result. The test asserts both replies
              share a status and a body shape, differing only in the fresh report_id every
              acceptance carries, and that only the resolvable one reached the store.
    Rejected: the 404 error.moderation.report_unknown_user this PR previously shipped. It
              manufactured an account-enumeration oracle from a check that needed none. The
              earlier defence — bounded to operator-pinned peers — 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. Also rejected: filing the orphan
              row anyway (the queue is a human's), and answering 202 with a differing body (the
              oracle one field along).
    Reverses: this PR's own decision 29 on that one point. The code is deleted from the catalog.

Unresolved review notes

  • nbf is honoured with zero leeway on the issuer's own clock; the verifier in v1 is always the issuer, so the skew tolerance the table gives nbf is nil here. Recorded, not changed.

  • Peer keys are operator-pinned and nothing can pin one yet (decision 24). POST /v1/federation/reports therefore answers 403 error.federation.peer_unknown to 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.rs rather than tests/blob.rs (decision 18); tests/blob.rs is untouched. A reviewer who expects the blob surface's cases in one file should say so.

  • federation::on_peer_blocked has 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_stable pins 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 a ReportClaim field fails there first, which is the intent and should not be "fixed" by regenerating the literal without thinking about the peers.

  • error.federation.circuit_open and error.federation.capability_expired remain 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's 401 carries 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/reports still 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 fact RegistrationSource, ShareSource and DropSource all wait on.

  • error.moderation.report_unknown_user makes intake an account-existence oracle for a pinned peer. Closed by finding 36: intake answers 202 either 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_LIFETIME is 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_epoch above i64::MAX is now refused at issue. Nothing mints one (epochs come from a roster), so this is a guard, not a behaviour change.

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

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

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

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

View logs

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