All 13 tasks complete (implementation shipped in #570–573; staging verification in #574). Archive via `openspec archive` (CLI 1.6.0): - Creates openspec/specs/federation-operations/spec.md — the new capability: durable federation queue, inbound replay defense, instance blocklist, published protocol doc, delivery observability (Purpose filled in; CLI leaves a TBD placeholder). - Applies the MODIFIED requirement to openspec/specs/social-federation: "Push delivery on local activity create" now guarantees persistent queueing + a "Fan-out survives a deploy" scenario. - Moves the change to openspec/changes/archive/2026-07-13-federation-hardening/. Both specs pass `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.6 KiB
social-federation Specification
Purpose
ActivityPub federation for the Journal, scoped to the follow graph between trails.cool instances: per-user actor objects with WebFinger discovery, per-user signing keypairs, a narrow follow-only inbox, an outbox of public activities, push delivery to remote followers on local activity create, outbound follows restricted to other trails instances, outbox-poll ingestion of remote trails activities, and audience-aware feed filtering. Local follow lifecycle and the feed surface itself live in social-follows; profile pages live in public-profiles.
Requirements
Requirement: Per-user actor objects with WebFinger discovery
The Journal SHALL serve an ActivityPub Person actor object at the user's canonical URL for any user with profile_visibility = 'public', and SHALL serve a WebFinger endpoint at /.well-known/webfinger resolving acct:user@domain to that actor IRI.
Scenario: Public user has a discoverable actor
- WHEN a remote client GETs
/.well-known/webfinger?resource=acct:bruno@trails.cool - THEN the response is a JRD object with a
linksarray includingrel="self"pointing athttps://trails.cool/users/bruno(the actor IRI)
Scenario: Public user actor object resolves
- WHEN a remote client GETs
https://{DOMAIN}/users/brunowithAccept: application/activity+json - THEN the response is a
Personactor object including the user's display name, public key, inbox IRI, outbox IRI, andsoftwarefield identifying the instance as trails.cool
Scenario: Private user is invisible to federation
- WHEN any federation request resolves a user whose
profile_visibility = 'private'— WebFinger lookup, actor IRI fetch, or follow attempt - THEN every endpoint returns HTTP 404 with no leak of user existence
Requirement: Per-user signing keypairs
Every local user SHALL have an asymmetric keypair (RSA 2048 or Ed25519). The public key SHALL be embedded in the actor object. The private key SHALL be encrypted at rest using a server-managed encryption key. New users SHALL get keys at registration; existing users SHALL be backfilled at deploy.
Scenario: Outgoing activity is signed with the user's key
- WHEN a local user originates a federation activity (Follow, Accept, Create, etc) that is delivered to a remote inbox
- THEN the HTTP request carries an HTTP Signature header signed with that user's private key, identifying the user's
keyIdso the remote can verify
Scenario: Existing-user backfill at deploy
- WHEN the federation feature flag is first enabled on an instance with pre-existing users
- THEN a one-shot job generates keypairs for every user lacking one before any federation traffic is permitted
Requirement: Narrow inbox — follow-graph activities only
The user inbox at https://{DOMAIN}/users/:username/inbox SHALL accept and process only Follow, Undo(Follow), Accept(Follow), and Reject(Follow) activities. Any other activity type SHALL be acknowledged with HTTP 202 and dropped without processing.
Scenario: Inbound Follow auto-accepts for public users
- WHEN a signed
Followfrom a remote actor targets a local user withprofile_visibility = 'public' - THEN the inbox records the follow row with the remote actor as follower, delivers
Accept(Follow)back, and returns HTTP 202
Scenario: Inbound Create is dropped silently
- WHEN a signed
Create(Note)is POSTed to a local user's inbox - THEN the response is HTTP 202 but no row is created in
activities, no row infollows; the activity is logged at debug level and discarded
Scenario: Inbound Follow to a private user is rejected
- WHEN a
Followtargets a local user whoseprofile_visibility = 'private' - THEN the inbox returns HTTP 404 (matching the actor's own 404) and no row is created
Scenario: Replay-protected
- WHEN the same signed activity is delivered twice within the signature's validity window
- THEN the second delivery is dropped (idempotent on activity IRI) and returns HTTP 202
Requirement: Outbox publishes user's public activities
The Journal SHALL serve a paginated outbox at https://{DOMAIN}/users/:username/outbox for any user with profile_visibility = 'public', listing the user's public activities as Create(Note) activities (or a documented AS extension type).
Scenario: Outbox lists public activities
- WHEN a remote client GETs an outbox URL with a valid HTTP Signature
- THEN the response is an
OrderedCollection(or paginated collection page) of the user'spublicactivities, most recent first
Scenario: Outbox excludes private and unlisted
- WHEN the outbox is fetched
- THEN the response includes only activities with
visibility = 'public';unlistedandprivateactivities never appear
Requirement: Push delivery on local activity create
The Journal SHALL deliver a Create(Note) activity to every accepted remote follower's inbox when a local user with profile_visibility = 'public' creates a new public activity. Delivery queueing and retry state SHALL be persistent: queued deliveries and scheduled retries survive process restarts.
Scenario: New public activity fans out
- WHEN a local user with N accepted remote followers creates a new public activity
- THEN N delivery jobs are enqueued (one per follower's inbox), each retrying with exponential backoff on 5xx, giving up after a documented retry budget
Scenario: Rate-limited per remote host
- WHEN multiple deliveries target the same remote host
- THEN they are rate-limited so we never exceed 1 request per second per remote host (configurable; chosen for safety, not throughput)
Scenario: Fan-out survives a deploy
- WHEN a deploy restarts the journal while fan-out deliveries are queued or awaiting retry
- THEN the deliveries complete after the restart without loss
Requirement: Outbound follows restricted to other trails instances
The Journal SHALL accept outbound follow requests against remote actor IRIs only when the target host self-identifies as a trails.cool instance. Follows targeting Mastodon, Pleroma, or other non-trails ActivityPub servers SHALL be refused at the API layer with a clear error and a link to the documented v1 limitation.
Scenario: Follow another trails instance
- WHEN a local user follows
@alice@other-trails.exampleand the remote actor'ssoftwarefield declarestrails.cool - THEN the follow row is created with
accepted_at = NULL(Pending), a signedFollowis delivered to the remote inbox, and the button shows Pending
Scenario: Refuse to follow a Mastodon user
- WHEN a local user attempts to follow
@alice@mastodon.social - THEN the API returns 4xx with an error message explaining "outbound federation to non-trails instances isn't supported yet" and links to the project documentation
Requirement: Outbox-poll ingestion of remote trails activities
The Journal SHALL periodically GET the outbox of every remote trails actor that at least one local user follows with accepted_at IS NOT NULL, store new activities locally for feed display, and rate-limit fetches per remote host.
Scenario: Poll cadence and scope
- WHEN the scheduled outbox-poll job runs
- THEN it fetches at most the 50 most recent items per remote actor, stores any new rows tagged with their audience, and skips actors polled within the last hour
Scenario: Polls are signed
- WHEN the poller fetches a remote outbox
- THEN the GET request carries an HTTP Signature using the actor key of one of the local users who follow the remote actor
Scenario: First poll triggered immediately on accepted follow
- WHEN a follow row transitions to
accepted_at IS NOT NULL - THEN an immediate one-off outbox-poll is enqueued for that specific actor
Scenario: Respect remote rate limiting
- WHEN a remote instance returns
429orRetry-After - THEN the poller backs off the entire host (not just the actor) for the indicated duration
Requirement: Audience-aware feed filtering
Activities cached from remote trails actors SHALL be tagged with their audience (public or followers-only). The social feed query SHALL return followers-only activities only to the specific local user who holds an accepted follow against the originating remote actor.
Scenario: Followers-only remote content reaches only the right viewer
- WHEN a remote actor publishes a followers-only activity, two local users A and B both have rows in the activity cache for that actor, but only A holds an accepted follow against the actor
- THEN A sees the activity in
/feedand B does not, even though the row exists in our database