Task group 4 of federation-hardening.
4.1 — FEDERATION.md at the repo root: actor discovery (WebFinger, actor,
NodeInfo), object/activity types with real JSON examples (Note, Create,
Delete, the narrow follow-graph inbox), addressing, HTTP-Signature
expectations, the two-layer dedup contract, durable delivery/retry
policy, and blocklist moderation semantics — precise enough for another
implementation to interoperate. Linked from README and docs/architecture.
4.2 — three prom-client metrics + a journal dashboard row:
- `federation_delivery_total{outcome}` — incremented in deliver-activity
(delivered/skipped/failed).
- `federation_inbox_dropped_total{reason}` — incremented at every inbox
drop (duplicate | blocked); this is the counter deferred from task 3.2.
- `federation_queue_depth` — gauge sampled at scrape time in
/api/metrics from PgBossMessageQueue.getDepth(); the restart-loss
regression detector.
Grafana journal.json gains a Federation row (delivery rate, queue depth,
inbox drops); the logs panels shift down to make room.
Verified: dashboard JSON valid; journal typecheck + lint clean; unit
suite 357 passing (route-template guard unaffected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.5 KiB
Federation protocol
trails.cool's Journal federates over ActivityPub,
implemented with Fedify. This document describes the
wire protocol precisely enough for another implementation to interoperate
deliberately — actor discovery, the object and activity types we emit and
accept, addressing, signatures, deduplication, delivery retry, and
moderation. Examples use trails.example for our instance and
remote.example for a peer.
Federation is per-instance opt-in (FEDERATION_ENABLED). When it is off,
every federation surface returns 404 — a disabled instance is
indistinguishable from one without the feature. Only users with
profile_visibility = 'public' federate; a private user's actor,
WebFinger, inbox, and outbox all 404, so their existence never leaks.
Actor discovery
WebFinger
GET /.well-known/webfinger?resource=acct:alice@trails.example resolves a
handle to an actor IRI:
{
"subject": "acct:alice@trails.example",
"links": [
{ "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" }
]
}
Actor
GET https://trails.example/users/alice with Accept: application/activity+json
returns a Person. The actor IRI and the human profile url are the same
by design (browsers get HTML at that URL via content negotiation):
{
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
"id": "https://trails.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice",
"summary": "trail runner",
"url": "https://trails.example/users/alice",
"inbox": "https://trails.example/users/alice/inbox",
"outbox": "https://trails.example/users/alice/outbox",
"publicKey": {
"id": "https://trails.example/users/alice#main-key",
"owner": "https://trails.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
},
"assertionMethod": [ { "type": "Multikey", "…": "…" } ],
"attachment": [
{ "type": "PropertyValue", "name": "🥾 trails.cool", "value": "<a href=\"https://trails.example/users/alice\" rel=\"me\">trails.example/users/alice</a>" },
{ "type": "PropertyValue", "name": "Instance", "value": "<a href=\"https://trails.example\">trails.example</a>" }
]
}
publicKey is the RSA key Mastodon reads for HTTP-Signature verification;
assertionMethod carries the same keys as Multikeys for newer stacks.
NodeInfo (software discovery)
GET /.well-known/nodeinfo links to GET /nodeinfo/2.1:
{
"version": "2.1",
"software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" },
"protocols": ["activitypub"],
"usage": { "users": {}, "localPosts": 0, "localComments": 0 }
}
software.name is the machine-readable "this is a trails instance" marker
used by the trails-to-trails outbound check. Usage counts are deliberately
zeroed — publishing per-instance counts is a privacy decision we have not
made.
Objects and activities
Activities correspond to a user's journal entries. The object model is
deliberately Mastodon-compatible: a Create(Note) whose HTML content
summarizes the activity and whose url links to the journal detail page.
(A first-class trails:Route object type is planned with route-federation;
today everything is a Note.)
Note
{
"id": "https://trails.example/activities/01H…",
"type": "Note",
"attributedTo": "https://trails.example/users/alice",
"content": "<p>Morning trail run — 12.4 km, 480 m up</p>",
"url": "https://trails.example/activities/01H…",
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
The Note IRI (/activities/<id>) is dereferenceable and serves
application/activity+json — Mastodon's search-fetch and strict re-fetch
of pushed objects both rely on this.
Create / Delete
A publish is a Create wrapping the Note; the activity id is the object IRI
with a #create fragment. A retraction is a Delete wrapping a Tombstone
at the same object IRI:
{ "id": "https://trails.example/activities/01H…#create", "type": "Create",
"actor": "https://trails.example/users/alice",
"object": { "…": "the Note above" },
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"] }
Note that a Delete poisons the object URI on the remote forever (remotes
tombstone it); re-publishing the same URI after a Delete is silently
refused by strict remotes.
Follow graph
The inbox is narrow — only follow-graph activities are processed;
anything else is acknowledged (202) and dropped.
| Inbound | Effect |
|---|---|
Follow (remote → local public actor) |
auto-accepted; we push back an Accept(Follow) |
Undo(Follow) |
removes the follow |
Accept(Follow) |
settles our outgoing Follow; triggers the first outbox poll |
Reject(Follow) |
drops our pending outgoing Follow |
Addressing
Public activities are addressed to https://www.w3.org/ns/activitystreams#Public
and push-delivered to each accepted remote follower's inbox (fan-out,
one delivery per follower). We do not implement shared-inbox delivery.
Remotes do not backfill history — only pushed or individually-fetched
objects appear on a peer.
Signatures
All inbound activities must carry a valid HTTP Signature; Fedify verifies it
against the sending actor's publicKey (fetched and cached). Unsigned or
badly-signed requests are rejected. Outbound deliveries are signed with the
sending user's key. An actor changing keys requires the remote to re-fetch
the actor document.
Deduplication
Delivery is at-least-once, so receivers must be idempotent. trails dedups inbound activities two ways:
Create(Note)— idempotent via a unique constraint on the activity's origin IRI (remote_origin_iri); a redelivered Create is a no-op.- Follow-graph activities (
Follow/Undo/Accept/Reject) — the activity IRI is recorded infederation_processed_activitieson first receipt (insert-or-drop before any side effect); a redelivery is dropped and counted (federation_inbox_dropped_total{reason="duplicate"}). Records are retained ≥ 30 days, which comfortably exceeds the HTTP-Signature date-freshness window, then swept.
Delivery retry
Delivery queueing and retry state are durable — they survive process
restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the
retry policy). On a 5xx or timeout, a delivery retries with exponential
backoff, giving up after a bounded budget (~8 attempts spanning roughly a
day) before a permanent failure is logged. Deliveries are paced to at most
1 request/second per remote host. Metrics:
federation_delivery_total{outcome} and federation_queue_depth.
Moderation
An operator can block a federation instance by domain (exact-host match). A blocked instance is inert in both directions:
- its inbound activities are silently dropped (
202, no error oracle) and counted (federation_inbox_dropped_total{reason="blocked"}); - it receives no deliveries (blocked recipients are filtered from fan-out);
- we never fetch its actors or outboxes.
Blocking is effective immediately (checked per request / per job, no cache).
The operator procedure (a SQL insert/delete against
journal.federation_blocked_instances) is documented in the
deployment runbook.
Kept current as federation capabilities change. Specs:
openspec/specs/social-federation and openspec/specs/federation-operations.