trails/openspec/specs/social-federation/spec.md
Ullrich Schäfer 42149d8e02
chore(openspec): archive federation-hardening
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>
2026-07-13 23:54:56 +02:00

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 links array including rel="self" pointing at https://trails.cool/users/bruno (the actor IRI)

Scenario: Public user actor object resolves

  • WHEN a remote client GETs https://{DOMAIN}/users/bruno with Accept: application/activity+json
  • THEN the response is a Person actor object including the user's display name, public key, inbox IRI, outbox IRI, and software field 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 keyId so 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 Follow from a remote actor targets a local user with profile_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 in follows; the activity is logged at debug level and discarded

Scenario: Inbound Follow to a private user is rejected

  • WHEN a Follow targets a local user whose profile_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's public activities, most recent first

Scenario: Outbox excludes private and unlisted

  • WHEN the outbox is fetched
  • THEN the response includes only activities with visibility = 'public'; unlisted and private activities 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.example and the remote actor's software field declares trails.cool
  • THEN the follow row is created with accepted_at = NULL (Pending), a signed Follow is 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 429 or Retry-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 /feed and B does not, even though the row exists in our database