Files
SocialPub/docs/INTEROP.md
T
thepraandClaude Opus 5.5 436f7da464
Build / Build (push) Successful in 5m11s
Deploy / privapub.thepra.dev (push) Successful in 5m48s
CDNs found by themselves, and servers followed through time
PrivaPub now finds CDNs three ways, best first: the address ranges the
CDNs publish (Cloudflare, Fastly, Amazon CloudFront, Bunny, Gcore,
Imperva), downloaded daily by CdnUpdater and kept in CdnRangeSet; the
CDN's fingerprint in the responses it already gets from a server
(EdgeHintsHandler on the federation client); and the networks that carry
only a CDN. The fixed ASN list is gone; ASNs shared with plain hosting
(AWS, DataPacket) no longer hide a server. A server's Geo records the
CDN, its domain and how it was found, and weekly snapshots now keep the
city and coordinates too.

Servers through time (ServerPlaces): /instances/:host/history lists a
server's weekly snapshots, a CDN-fronted server's geo names the CDN's
domain and where the server was before it (before_cdn), and
/api/privapub/v1/cdns and /cdns/:domain group servers by CDN with week
by week who joined and who left. Owner decisions recorded in ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:33:39 +02:00

901 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Interop: what every peer sends, what it expects, and what PrivaPub still drops
Research as of **2026-10-01**: the platforms' source on their default branches, live ActivityPub fetches from large
instances, release notes and the FEP repository. Version numbers are what was current that day. Claims the
research could not confirm from a primary source are marked *(unconfirmed)*. The sources are at the end.
This file is for two readers:
- **Whoever changes federation code.** Every gotcha below has broken somebody.
- **Whoever builds the rich client.** That client shows every kind of fediverse content in one place, with a secondary
"details" view of the raw object and how it reached us. Section 4 says what the server must keep for it.
Priorities, used throughout:
| Priority | Meaning |
|---|---|
| **P1** | Breaks interop, or loses content the user would see. |
| **P2** | Degrades the experience. |
| **P3** | Nice to have. |
## 1. Where PrivaPub stands (checked in the code, 2026-10-01)
**Already right**
- **Public addressing:** all three spellings are recognised (`…#Public`, `as:Public`, `Public`).
- **Undo:** embeds its whole object, which Misskey needs; it fetches the object only when it isn't embedded.
- **Content types:**
- Served documents are `application/activity+json; charset=utf-8`, which is on Lemmy's exact allowlist and is
accepted by GoToSocial and Misskey.
- WebFinger answers `application/jrd+json`, and answers for the actor URL as well as `acct:`. Akkoma and
Iceshrimp.NET require both. A bare `user@domain` or `@user@domain` is taken as an `acct:` too, as Mastodon,
GoToSocial and Pleroma take it (2026-10-03).
- **SSRF guard:**
- Its IPv6 rule is an allowlist (`2000::/3` only), so IPv4-compatible `::a.b.c.d`, NAT64 and the other tricks behind
Mastodon's GHSA-vwhj (Jul 2026) are already refused.
- It caps fetches at 1 MB. Misskey caps at 256 KiB, so keep our own documents well under that.
- **Attachments:**
- Up to 16 are kept. Mastodon drops anything past 4; Pixelfed albums and Threads carousels go beyond it.
- `{type: Link}` attachments are not mistaken for media.
- **Deleted posts** answer 410 with a `Tombstone`. Actor `published` is truncated to the day. The `webfinger` property
(FEP-2c59) is on every actor.
- **Live check:** the whole follow, post, reply, like, boost, DM, edit and delete set round-trips with GoToSocial
0.22.1 (`tools/pasture/`).
**Wrong at the time of the research (P1, cheap; all three fixed in v1.7.0)**
- **`summary` is read as a content warning on every object type.** It is one only on a `Note`, or when
`sensitive: true` is set. Elsewhere it is something else:
| Sender | What `summary` holds |
|---|---|
| WordPress, WriteFreely, Ghost, NodeBB (`Article`) | a teaser or excerpt |
| Mobilizon, Gancio (`Event`) | the date and address, or the whole description |
| Funkwhale (`Audio`) | a line of hashtags |
| Mbin (`Page`) | a short title plus tags |
Today all of these arrive hidden behind a warning, and marked sensitive.
- **Persona and group usernames are only lowercased.** Mastodon accepts `[a-z0-9_]` with `.` and `-` only inside the
name; Misskey `^\w([\w-.]*\w)?$`, 128 characters at most. A persona named outside that is unreachable from either.
- **Our JSON-LD context does not define `postingRestrictedToMods`.** Iceshrimp.NET runs full JSON-LD expansion and
silently drops every undefined term. Any term we add later (`quote`, `Emoji`, `EmojiReact`, `interactionPolicy`,
`votersCount`) must be defined in `ActivityPubRenderer.Context()` in the same commit.
**Smaller**
- No `Vary: Accept` on actor and object URLs, although they answer HTML or JSON depending on `Accept`.
- Only `create-…` and `announce-…` activity ids dereference. `follow-`, `like-`, `accept-` and `undo-…` answer 404.
That is harmless while objects are embedded, but every id should resolve.
## 2. Rules that hold across platforms
| # | Rule | What PrivaPub must do | P |
|---|---|---|---|
| W1 | `url`, `icon`, `image`, `attributedTo`, `actor`, `tag`, `attachment` and `alsoKnownAs` can each be a single value, an object or an array. The `url` array matters most: PeerTube video files, Funkwhale streams, and Bridgy posts, whose `rel: canonical` link is `at://…`. | Parse every shape. For "open original", use the `text/html` Link. Keep every other Link as a media variant. | P1 |
| W2 | `summary` is a content warning only on a `Note`, or when `sensitive: true`. | See §1. On other types store it as an excerpt or description. NodeBB 4.16 honours a CW only on a sensitive Note. | P1 |
| W3 | `content` can be Markdown. PeerTube descriptions and comments carry `mediaType: text/markdown`. | Render it, then sanitise. Keep `source{content, mediaType}`: Markdown, BBCode, `text/x.misskeymarkdown` (MFM). | P1 |
| W4 | `mediaType` is missing or wrong: Bridgy media has none, Funkwhale hard-codes `audio/mpeg`, Gancio `image/jpeg`. Mastodon types every attachment `Document`. | Infer it from the object or attachment type, then sniff it in the media proxy. | P1 |
| W5 | Thumbnails live in five places: attachment `icon` (Mastodon), object `icon[]` (PeerTube), object `preview` (Loops), `image` (Bridgy video; Ghost as a bare string; WordPress), and `icon` (Plume). | Keep them all; choose one per kind. | P1 |
| W6 | `Accept`, `Reject` and `TentativeAccept` are not always follow answers. Friendica and Hubzilla use them as event RSVPs; Mobilizon answers a `Join`; GoToSocial answers interaction requests with `result`; Mastodon answers a `QuoteRequest`. | Route on what the object *is*. | P1 |
| W7 | `id` is not the page a person opens. WordPress uses `?p=123`, Ghost `/.ghost/…`, Bridgy `/convert/ap/at://…`. | Link to `url`, never to `id`. | P1 |
| W8 | Rich types are updated in place: PeerTube live state, WordPress (on every save), Mobilizon. A poll's counts are refreshed with an `Update{Question}` that changes nothing else. | Apply Updates to every kind. An Update with no newer `updated` is a refresh, never an edit revision. Mastodon applies the same rule. | P1 |
| W9 | A `Delete` can arrive before its `Create`, and relays and forwarders re-send old Creates. | Keep tombstones so deleted posts stay deleted. When the deleter is not the author, confirm with the origin: 404 or 410 means deleted. | P1 |
| W10 | Time comes in seconds (Mastodon), milliseconds (Misskey, us), or with offsets. `-00:00` means floating local time (Hubzilla events). `duration` is ISO 8601 (`PT6299S`). GoToSocial rejects a status whose `updated` is earlier than `published`. | Parse all of these, and clamp future times. Order by arrival (PrivacyIds.Arrived). Never emit `updated` < `published`. | P1 |
| W11 | Language may be in `contentMap`, in `@context[].@language` (Pleroma 2.9+), or a `language{identifier,name}` object (Lemmy, PeerTube). Akkoma replaces `content` with the *first* `contentMap` entry. | Read all three. When sending, put `content` and the primary `contentMap` entry first and keep them equal. | P2 |
| W12 | Alt text: `name` (Mastodon), `summary` (GoToSocial 0.20.0, Akkoma reads it first), or their `*Map` forms. Avatar and header alt is in `icon.name`/`image.name`, or `summary` on Mastodon. | Read both; send `name`. | P2 |
| W13 | `"id": null` objects (Akkoma); a `Tombstone` served with **200** as a soft delete (FEP-4f05: NodeBB, Discourse); 410 for deleted GoToSocial 0.22 statuses. | Accept a null id inside an activity; never emit one. Any `Tombstone`, whatever the status code, means deleted. | P2 |
| W14 | Size limits on the receiving side: Misskey reads at most 256 KiB, truncates text at 8192 characters, CW 512, poll choice 256, alt 512. GoToSocial takes emoji up to 100 KB. Peers cap fetches at about 1 MB. | Accept long posts from others. Keep our own documents small. | P2 |
| W15 | Hashtag `name` comes with or without `#`. Mastodon normalises with NFKC + lowercase (watch Turkish `İ`). Lemmy adds an automatic `#<community>` tag to every post. | Normalise the same way; ignore Lemmy's automatic tag. | P3 |
## 3. Per platform
### Mastodon: 4.7.2 (2026-09-15); mastodon.social runs 4.8 alpha; client `api_versions.mastodon` = 11
**Emits**
- **Objects and activities:**
- Objects: `Note` and `Question` only.
- Federated `Block`.
- `Add`/`Remove` for pins, featured hashtags and featured collections (4.6, FEP-7aa9).
- `Move`.
- `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`.
- `FeatureRequest`/`FeatureAuthorization` (4.6).
- **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+).
- **Interaction counts:** `likes` and `shares` carry `totalItems`.
- **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`.
- **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967).
- **Attachments:** `duration`, and `icon` thumbnails.
- **Actors:**
- `webfinger`, `featuredCollections`, `interactionPolicy.canFeature`, `attributionDomains`;
- `memorial`, `suspended`, `indexable`, `discoverable`;
- avatar and header alt text in `summary`;
- several keys allowed (4.6).
- **Ids:** new accounts get numeric actor ids (4.5). Remote renames are accepted, keyed on the actor `id` (4.7).
**Expects**
- **Fetched documents:**
- `id` equals the URL requested, exactly.
- The type is `activity+json`, or `ld+json` with the profile.
- `@context` includes the ActivityStreams URL.
- **WebFinger:** it loops back to the same `id`.
- **draft-cavage signatures:**
- `(request-target)` includes the query string (4.3).
- `digest` is signed on POST and `host` on GET.
- The window is 12 h, with 1 h of skew.
- Mastodon no longer signs `Accept` (4.6). Never require it.
- **RFC 9421:** verified by default since 4.5.0.
- A request carries one signature only.
- It must cover `@method`, `@target-uri` and `content-digest`, and include `created` and `keyid`.
- **4.7 double-knocks:** it signs draft-cavage first, and if we answer 400 or 401 it retries with RFC 9421. A 400 for
any other reason therefore triggers a retry we would fail.
- Mastodon answers **503** when it temporarily cannot fetch a key; that is a retry, not a failure.
- **Edits:** an Update counts as an edit only with a newer `updated`.
- **Quotes:** a post with no `canQuote` **cannot be quoted** by Mastodon users at all. A quote without a valid stamp
stays "pending", and only its fallback link shows.
- **Fetch all replies** is always on (4.5). Mastodon crawls `replies` as its instance actor, up to 500 per post, and
**deletes its copy** when a refetch answers 404.
- **Converted types:** `Article`, `Page`, `Event`, `Video`, `Audio` and `Image` become `<h2>name</h2>` + summary +
link. **The body is dropped.** Only `Mention` tags notify. Previews come from fetching the page; FEP-8967's
`preview` is ignored.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Quotes: read all keys; verify the `QuoteAuthorization` (type, host, `attributedTo`, `interactingObject`, `interactionTarget`; GHSA-vg36 compared domains only); revocation; strip the `.quote-inline` fallback | P1 | `Status.quote{state, quoted_status}`, `quotes_count`, `/statuses/:id/quotes`, `quote`/`quoted_update` notifications, `api_versions.mastodon ≥ 7` |
| Being quotable: emit `canQuote`; answer `QuoteRequest` with `Accept{object: request id, result: stamp}`; serve and revoke stamps | P2 | `Status.quote_approval`, `PUT /statuses/:id/interaction_policy` |
| Polls (see §3 Misskey for vote shapes) | P1 | `Status.poll`, `/polls/:id`, `/polls/:id/votes`, `poll` notification |
| Custom emoji on posts, names, fields and poll options, proxied, refreshed by `updated` | P1 | `Status.emojis`, `Account.emojis` |
| Link attachments as the card source | P1 | `Status.card` |
| Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account, 7-day lock); move each persona's follow | P1 | `Account.moved` |
| Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` |
| Inbound `Block`: stop delivering, hide | P2 | `relationship.blocked_by` |
| `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` |
| Remote like and boost totals | P2 | counts |
| Publish `context` and a paged `replies` | P2 | — |
| `FeatureRequest`: send `Reject` (or implement FEP-7aa9) | P3 | — |
**Pasture evidence (2026-10-03, Mastodon v4.7.3, `tools/pasture/scenarios/mastodon.sh`):** 49 checks pass. They cover:
- discovery, follows and locked follows both ways;
- public, CW and followers-only posts (the last answering 404 unsigned);
- replies threading both ways;
- likes, boosts and their undos both ways, with counts;
- DMs both ways;
- polls and votes both ways;
- FEP-044f quotes approved both ways;
- images with alt text both ways, ours through `/media/proxy`;
- edits with history both ways;
- deletes both ways, ours answering 410;
- a Flag reaching Mastodon's moderators from the instance actor;
- a circle request held for its owner and approved;
- unfollow and block;
- statistics naming `mastodon.test` as mastodon with no account names.
Findings:
- **Mastodon 4.7 names its actors by number**: `https://mastodon.test/ap/users/<id>`, inbox `…/ap/users/<id>/inbox`. Nothing here may assume
`/users/<name>`.
- **It dropped circle posts** until each member's copy named that member (owner decision 2026-10-04, v1.19.0).
- A post addressed only to `[circle, circle/flock]` parses as `direct` there, and Mastodon keeps a `direct` post
only if it names a local account or arrived in a known account's inbox (`Create#addresses_local_accounts?`).
- But `ActivityPub::InboxesController#account_required?` looks only at `params[:account_username]`. A delivery to
the numeric `/ap/users/:account_id/inbox` it now advertises therefore reaches the worker with no recipient and
is rejected.
- DMs are unaffected because they name the recipient.
- Each member's copy now names that member in `cc` and mentions them silently, so Mastodon keeps it, and its signed
refetch (`ActivityPub::FetchRemoteStatusService`, by its instance actor) gets the post back naming the members on
that server. The same holds for a followers-only post's refetch, which used to answer 404, and a 404 on refetch
makes Mastodon delete its copy. Reporting the numeric-inbox recipient loss upstream is still worth doing.
- **With SecureMode on** (all six peers, 2026-10-04) every federation check passes: Mastodon, GoToSocial, Misskey,
Sharkey, Akkoma and Lemmy all sign their fetches, and fetch our instance actor's key unsigned first.
- Inbound `Block` from Mastodon is not enforced yet (P7).
### GoToSocial: 0.22.1 (2026-07-20)
**Emits**
- **Objects:** `Note` and `Question` only.
- **Interaction policies** on every status: `canLike`, `canReply`, `canAnnounce` and `canQuote`, each split into
`automaticApproval` and `manualApproval`.
- Defaults: open on public and unlisted posts.
- On followers-only posts, liking and replying are limited to the author, followers and mentioned accounts, and
boosting to the author.
- `canQuote` is author-only on every post (0.21).
- **Since 0.21 it asks politely:** `LikeRequest`, `ReplyRequest` and `AnnounceRequest`, with the interaction in
`instrument`. The answer is `Accept`/`Reject` whose `result` is a `*Authorization`. The interaction then carries
`replyAuthorization` (with `approvedBy` as the legacy fallback).
- **Keys and actors:**
- The key id is `…/main-key` (no `#`), and its document is a stub actor.
- There is **no `sharedInbox`**.
- `hidesToPublicFromUnauthedWeb` / `hidesCcPublicFromUnauthedWeb`, `indexable`.
- `featured` holds URIs only, and changes to it are never announced, so read it instead.
- **What it leaves out:**
- `context`;
- `likes`/`shares`;
- attachment `width`/`height`.
- **Deleted statuses:** 0.22 keeps a stub and answers 410.
**Expects**
- **Signed requests:** every GET and POST is signed, draft-cavage only, with RSA keys. **No RFC 9421 in either
direction.**
- **Key handshake:** the instance actor and key documents must be served **unsigned**, or both sides deadlock fetching
each other's keys. Ours are: SecureMode exempts the instance actor.
- **Content-Type:** an inbox POST must be `activity+json`, or `ld+json` with the profile. Anything else gets 406.
- **Activities:** one without an `id` is dropped. A 400 is never retried.
- **Keys:** a changed public key on refresh is refused. **Never rotate keys silently.**
- **Interaction policies:**
- Third-party GoToSocial servers drop replies that have no valid `replyAuthorization`.
- On followers-only GoToSocial posts, send `ReplyRequest` / `LikeRequest` instead of a bare Create or Like.
- **Rate limit:** 300 requests per 5 minutes per IP, answered with 503 and `Retry-After`.
- **Not accepted:** top-level `Audio`.
- **Timelines:** its cached home timeline can miss new posts. Check a delivery by URI, not through its timelines; see
CLAUDE.md, "Testing".
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Store remote `interactionPolicy` (with GoToSocial's defaults); disable or mark actions | P1 | GoToSocial-style `Status.interaction_policy` |
| Send `ReplyRequest`/`LikeRequest` where approval is needed; handle `Accept{result}`/`Reject`; attach the authorization | P1 | own: pending/approved/rejected on our own reply |
| Honour 503 with `Retry-After` in delivery and in the proxy | P1 | — |
| Respect `hides*FromUnauthedWeb` on our public pages; emit it for personas (it suits the privacy design) | P2 | — |
| Measure media size when proxying | P2 | `MediaAttachment.meta` |
| Only advertise the policies we enforce | P2 | — |
**Pasture evidence (2026-10-03, GoToSocial 0.22.1, `tools/pasture/scenarios/gts.sh`):** 37 checks pass, three runs in a
row. That is the original 33 plus four on statistics: described as gotosocial, inbound and outbound traffic counted,
no account named. Since 2026-10-04 (v1.19.0) circle posts reach a GoToSocial member too. GoToSocial files a post for
neither the public nor the author's followers as a direct message, like our DMs, and shows it only to the accounts it
mentions. Being in `cc` stored it but left it invisible, so each member's copy also mentions that member silently.
Such posts are then found in the member's conversations, never by a search on their URI.
### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17
Firefish is dead (its site has answered 410 since February 2025).
**Emits**
- **Text and quotes:**
- `_misskey_content` plus `source{content, mediaType: text/x.misskeymarkdown}` (MFM).
- The quote is `_misskey_quote`/`quoteUrl`, plus a `RE:` fallback inside the content.
- An empty CW is a zero-width space.
- **Attachments:** each carries its own `sensitive`.
- **Emoji tags** carry `_misskey_license`.
- **Polls:** `Question` with `oneOf`/`anyOf`. Counts are in `replies.totalItems` per option. `endTime` and `closed` are
set; there is **no `votersCount`**.
- A vote is `Create{Note{name, inReplyTo, to:[owner]}}`, one per choice.
- Every vote triggers an `Update{Question}`.
- **Reactions** are `Like{content: "👍"|":name:", _misskey_reaction, tag:[Emoji]}`.
- One reaction per user per note; a plain like is `❤`.
- They are sent to the author **and to the reactor's followers**.
- **Actors:**
- `isCat`, `_misskey_summary`, `_misskey_followedMessage`;
- `_misskey_requireSigninToViewContents`;
- `_misskey_makeNotesFollowersOnlyBefore`/`HiddenBefore` (seconds; a negative value is relative to now);
- `vcard:bday`, `vcard:Address`, `backgroundUrl`.
- **Vanilla Misskey has no edits.**
- **Sharkey adds:**
- edits;
- a FEP-e232 quote `Link` tag (it deliberately leaves out `quote`);
- a `replies` collection;
- `hideOnlineStatus`, `noindex`, `enableRss`, `speakAsCat`.
- It sends contentless Likes only to Mastodon-like peers, so **we always receive Like+content**.
- **Iceshrimp.NET adds:**
- `EmojiReact`, several per user, and `:name@host:` for remote emoji;
- a FEP-7888 `context`;
- `htmlMfm: true` (FEP-c16b);
- every `QuoteRequest` is auto-accepted;
- `Bite`, `pronouns`.
- **CherryPick adds:** events on a plain Note (`startTime`/`endTime`), `deleteAt`, and federated chat
(`_misskey_talk: true`).
**Expects**
- **Inbound signatures:**
- draft-cavage over `(request-target) host date digest`;
- at most 300 s of skew;
- since 2026.10.0, the query string is included in `(request-target)`.
- **No RFC 9421 anywhere in the family.**
- **Activities:**
- An activity's `id` must be on the signer's host.
- Activities forwarded on behalf of someone else are refused. A group must `Announce`.
- Misskey answers 202 even when it drops something, so errors stay invisible.
- **Fetched documents:** request URL = final URL = `id`; ≤256 KiB; `activity+json` or `ld+json`.
- **Actor collections** must be on the actor's host.
- **Visibility:** Misskey recognises followers-only by the author's own `followers` URL, matched exactly. Otherwise:
- **A "specified" (direct) note with no resolvable recipients that Misskey fetches by URL is stored as public.**
Circle objects must therefore never be served to an unauthorised fetcher. They aren't: 404.
- **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both.
- **Reactions:** must be `:name:` with no host, plus an Emoji tag, or they fall back to ❤.
- **Article/Page titles** are never shown in Misskey's web UI.
- **Iceshrimp.NET:**
- full JSON-LD expansion (see §1);
- `@graph`, `@reverse` and `@included` are refused;
- every actor must resolve through WebFinger;
- `preferredUsername` must be unique per domain. PrivaPub shares one name space across personas and groups, so this
holds.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Emoji reactions in all three inbound forms: Like with `content`/`_misskey_reaction`, `EmojiReact`, and `Dislike`-as-un-like (Sharkey). Normalise `:n:`, `:n@host:` and `n@host`. Store per actor, emoji and activity, including reactions to remote posts. Undo by id. | P1 | `emoji_reactions` and `pleroma.emoji_reactions` `[{name,count,me,url,static_url}]` (Phanpy reads the first); `PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji` |
| Outbound reaction as `EmojiReact{content: ":name:", tag:[Emoji]}`. A plain Like stays a favourite. | P2 | same |
| Polls: per-option counts (fall back to `_misskey_votes`); `votersCount` null when absent; `Update{Question}` is a refresh; an inbound `Note{name, inReplyTo: question}` with no content is a vote, never a reply | P1 | `Status.poll` |
| Keep MFM source; let the sanitiser keep `<span class="mfm-*" data-mfm-*>` and `<ruby>` (FEP-c16b) | P2 | `text`, `content_type`; the rich client renders MFM from the source |
| Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` |
| Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — |
**Pasture evidence (2026-10-03, Misskey 2026.10.0, `tools/pasture/scenarios/misskey.sh`):** 35 checks pass:
- discovery and follows both ways;
- posts, CW and the MFM `source` kept;
- replies threading both ways;
- 👍 and 🎉 reactions both ways, and their withdrawal;
- a like counting as a reaction;
- legacy quotes both ways (`_misskey_quote` in, `renoteId` out);
- polls and votes both ways;
- `specified` notes as DMs both ways;
- images with alt text (`comment`) both ways;
- deletes both ways, unfollow and block;
- statistics.
A new Misskey 2026 starts with `federation: none`.
**Pasture evidence (2026-10-03, Sharkey 2025.4.7, `tools/pasture/scenarios/sharkey.sh`):** 40 checks pass, the whole
Misskey scenario under Sharkey's name and then what only Sharkey does:
- its edits arrive as edits, and ours reach it;
- its quote carries the FEP-e232 `Link` tag and is understood.
### Pleroma 2.10.2 and Akkoma 3.20.1
**Emits**
- **Notes:**
- `@context` with the instance's own `litepub-0.1.jsonld` URL (never fetch it) and `@language`;
- `source{content, mediaType: text/markdown}`;
- `context` and `conversation` holding the same value;
- `quoteUrl`/`quoteUri`.
- **Edit history:** `formerRepresentations`, an OrderedCollection of earlier versions.
- **Reactions:** `EmojiReact{content: ":name:", tag:[Emoji]}`, several per user; separate from Like.
- **Polls:** `votersCount` (Pleroma 2.10.1, Akkoma 3.20). A vote is a `Note{name, inReplyTo, to:[], cc:[owner]}`.
An open poll's end is in `closed`, and Akkoma sends no `endTime` (pasture, 3.20.1).
- **Pleroma only:**
- `ChatMessage`, sent only to actors with `capabilities.acceptsChatMessages`;
- `Listen{Audio}`;
- outgoing `Block` is on by default.
- **Akkoma only:**
- local-only posts are addressed to `<base>/#Public`, which is **not** public;
- FEP-2c59.
**Expects**
- **Pleroma's inbox guard answers 400 for unknown activity types:** `Move`, `QuoteRequest` and `Bite` are not on its
list (develop, 2026-09-30). Treat that 4xx as final.
- **Signatures (Akkoma):** `host` must be signed and match; signatures up to 2 h old and up to 40 min in the future.
- **Visibility is guessed from addresses:** a post is private only if an address in `to` contains `/followers` or its
`cc` is not empty; otherwise it is direct. Our followers-only posts therefore name `/groupies` in `cc` too.
- **Activity ids** must be at least 8 bytes.
- **ObjectAgePolicy** (default in both) delists anything older than 7 days, so `published` must be accurate.
- **Quotes:** Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. **Neither reads FEP-044f
`quote`**, so send `quoteUri` and `_misskey_quote` as well.
- **Edits:** a changed `name` is ignored on update.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Language from `@context[].@language` | P1 | `Status.language` |
| `formerRepresentations` → our revisions | P2 | `/statuses/:id/history` |
| `context`/`conversation` threading; parents and quotes we could not fetch, kept as URIs | P2 | `pleroma.context`; `akkoma.in_reply_to_apid`, `akkoma.quote_apid` precedent |
| `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations |
| `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own |
**Pasture evidence (2026-10-03, Akkoma 3.20.1, `tools/pasture/scenarios/akkoma.sh`):** 44 checks pass, three runs in a
row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered:
- discovery and follows both ways;
- posts, CW and followers-only posts;
- the `published` time kept;
- replies both ways and their notification;
- likes and boosts both ways with their undos;
- `EmojiReact` both ways and its withdrawal;
- DMs both ways and off public timelines;
- polls and votes both ways;
- quotes both ways (`quote_id` out of its API, `quoteUri` in ours);
- images with alt text both ways;
- edits with history, and deletes, both ways;
- unfollow, block and unblock;
- statistics.
It found two bugs, both fixed:
- **Followers-only posts arrived as DMs.** Akkoma, like Pleroma, calls a post private only if an address in `to` contains
`/followers` or its `cc` is not empty. Ours is `/groupies` and a post mentioning nobody had an empty `cc`. Followers-only
posts now name the followers collection in `cc` as well, which tells nobody anything new.
- **Open polls were shown as ended, and votes refused.** An open Akkoma poll carries its end in `closed`, with no
`endTime`. A `closed` in the future is now read as the end.
Seen along the way:
- Its Linkify never takes `@user@host.test` for a mention, whatever `validate_tld` says, so in the pasture Akkoma
addresses us with Pleroma's `to[]`. Real top-level domains are unaffected.
- It records our `Block` (`user_relationships`) but never reports a remote blocker as `blocked_by`.
- Its streamer crashes rendering a new DM conversation (`ConversationView`, a nil `last_status`); delivery is unaffected.
### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May
join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled`
on a Page: pins live in `featured`, locks in `Lock`.
**Emits**
- **Group:**
- `summary` (sidebar HTML) and `source` (sidebar Markdown); a plain `description` in 1.0;
- `sensitive`; `attributedTo` → the moderators collection; `featured`; `postingRestrictedToMods`; `language[]`;
- 1.0 adds: `manuallyApprovesFollowers` for private communities, `discoverable: false` for unlisted ones, and the
community's post tags in `tag[]` as `CommunityPostTag` with colour slots `color01`–`color10`.
- **Page (post):**
- `name`, `content` and `source`;
- a link post is `attachment[0] = Link{href, mediaType}`, with `image` as the thumbnail;
- 1.0 image posts are `Image{url, name}`, where `name` is the alt text;
- `language{identifier, name}`, `audience`, `to: [community, Public]`;
- an automatic `#<community>` hashtag;
- 1.0 adds a Mention of the community and `context`.
- **Note (comment):** `distinguished`.
- **Votes:** `Like`/`Dislike` as `{actor, object, audience}` with no `to`/`cc`, and their `Undo`.
- **Moderation:**
- a `Delete` with a `summary` is a mod removal (1.0 adds `withReplies`); its `Undo` restores;
- `Lock`/`Undo{Lock}` (1.0 also on comments);
- a ban is `Block{target, removeData, summary, endTime}` (0.19 also sends `expires`);
- `Add`/`Remove` of featured posts and moderators;
- `Update{Group}` **by the moderator Person**;
- 1.0 adds `Warn` and `Resolve{Flag}`.
- **Delivery:** everything travels inside the community's `Announce`. For a new post Lemmy also sends a compatibility
`Announce(Page)` with a synthetic id; deduplicate it.
- **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0.
- **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post
thread**. Group threads by the root post, never by comparing `context` strings.
- **Feeds:** multi-communities are 1.0's `type: Feed` actors.
**Expects**
- **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with
the profile; and `id` equals the URL fetched.
- **Addressing:**
- Create, Update, Lock, Delete and Block **must carry both `to` and `cc` arrays**.
- A post's community is the first Group found in `to` ∪ `cc`.
- In a public community, the object, the activity **and the Announce** all include Public.
- **Accepted types:** Page, Article, Note, Video and Event become posts. **`Question` is dropped.**
- **Anti-spam:** activities for a community are accepted only if a local user follows it.
- **Actors:** a Create, vote, moderation action or Flag must come from a Person, Service or Organization. **A Group or
Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by an
Application instance actor, probably fail *(unconfirmed)*.
- **Flags:** exactly one `to` (community or site); `object` a URL or an array; the reason in `summary` or `content`.
- **Moderation trust:** an action is trusted when it is on the community's or the object's host (FEP-fe34), or when its
actor is in the moderators list Lemmy fetched.
- **1.0 hides a local user's post or comment in a remote community until that community Announces it back.** A
community we host must therefore announce to the author's own instance too.
- **Refused:** Lemmy does not accept an incoming `Announce(Page)`.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| `Dislike` and its `Undo`: a vote ledger per object (actor, ±1, activity, relaying group, time) | P1 | `favourites_count` = upvotes; own `privapub.vote{score, up, down, mine}`, `POST …/vote` |
| Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. | P1 | — |
| Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` |
| Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` |
| `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` |
| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — |
| Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — |
| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — |
| `Warn` → `moderation_warning` notification; `Resolve{Flag}` | P2 | AccountWarning |
| Remote communities: `description`, `language[]`, private (`locked`), `discoverable`; post tags | P2 | `Account.locked`, own `privapub.flairs[]` |
| Serve our communities' collections the way Lemmy reads them: inline outbox of `Announce{Create{Page}}`, inline featured Pages, inline moderators. Lemmy does not page. | P2 | — |
| `Feed` actors | P2 | group-like account |
| Read 1.0 `context`, grouped by root post; cross-post detection by URL | P3 | — |
**Pasture evidence (2026-10-03, Lemmy 1.0.0-beta.2, `tools/pasture/scenarios/lemmy.sh`):** 20 checks pass and 3 are
expected failures:
- communities both ways: Lemmy follows ours and alice follows Lemmy's, each Accept arriving;
- a Lemmy thread in our community arrives with its title, and our titled community post reaches Lemmy;
- the Lemmy community's Announce brings its thread to alice's home;
- alice's post mentioning a Lemmy community lands in it, titled from its first line (Lemmy repeats that line in the
body);
- comments both ways, and alice's like counted as an upvote;
- private messages both ways: 1.0 takes our single-recipient direct `Note`, and sends its own as `Note`s;
- statistics.
What it showed:
- **1.0 keeps its user's thread in a remote community `federation_pending` until the community announces it back**, and
clears the flag *before* answering that echo 400 (`Object is not remote`). Without the echo the thread stays pending,
so `GroupDistributor` sends the `Announce{Create}` to the author's own server on purpose.
- **Every bare `Announce{object}` is answered 400** (`Failed to parse object`: Lemmy dereferences it expecting an
activity), as Lemmy answers the compatibility `Announce(Page)` it sends itself. Both 400s show up as dead deliveries
in the statistics.
- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`. They are
dropped as `unsupported` until P7 (expected failures), as is a moderator's removal.
- Lemmy logs no refused activity at `warn`; the reason is in the 400's body, which our delivery does not keep. The
scenario's API notes: `sort` values are lowercase (`new`), private messages and mentions are in
`account/notification/list`, and `resolve_object` takes both `!community@host` and `@user@host`.
- **1.0 sends nothing it queued for a server before it started that server's send worker.** A worker starts, up to a
minute after the server is first seen, at the newest activity and skips everything older. On a clean pasture its
first Follow of our community was lost, so the scenario waits for the worker (`federation_queue_state`). On the
public network the same applies to the first thing a Lemmy sends to a PrivaPub it has just discovered.
- 0.19 cannot join the pasture: its rustls trusts only its bundled roots, never Caddy's CA.
### PieFed 1.7.17 and Mbin 1.10.1
- **PieFed sends Lemmy's set plus:**
- posts: `commentsEnabled`, `stickied`, `nsfl`, `genAI`, `searchableBy`, `canQuote`;
- galleries of several Documents;
- **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s;
- flairs in two dialects;
- comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities;
- `Move{object: post}` between communities;
- `Feed` actors;
- emoji Likes and `EmojiReact` count as upvotes.
- **How PieFed formats what it sends us:**
- It **chooses the format by our NodeInfo software name**, so keep that accurate.
- Software it lists as microblogs gets a bare `Announce(URL)`.
- A poll vote is recognised only when it is a `Note{name, inReplyTo}` **without `published`**.
- Batched Announces (FEP-1a11) go only to PieFed and Pylova.
- **Mbin:**
- Thread `summary` is "short title + tags", so it is **not a CW**.
- On link threads `source` is a **plain URL string**.
- `commentsEnabled` and `stickied` are sent.
- A Person's `Announce` counts as an upvote; Mbin reads the `likes`/`dislikes`/`shares` counts we publish.
- Private messages are `ChatMessage`.
- The magazine outbox is empty.
- Mbin also auto-ingests Mastodon posts by hashtag and Announces them.
- **Gaps:**
- **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string.
- **P2:** galleries; community polls (vote without `published`); post `Move`; `repliesEnabled`; `nsfl`; flairs;
publish `likes`/`shares` totals.
- **P3:** `genAI`; accepted answers; batched Announces.
### NodeBB 4.16, Discourse, Friendica
- **NodeBB:**
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
- Categories are `Group`s **without `followers`**.
- `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`.
- Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors.
- It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd).
- **Discourse** (plugin, semi-dormant): categories and tags are Groups. "Full Topic" mode makes the topic an
OrderedCollection used as `context`.
- **Friendica** (2026.05-1):
- Group accounts relay with `Announce(object)`.
- Titled posts are `Page`/`Article`; it sends `Dislike`.
- `instrument{Service}` names the software.
- It sends **`Follow` with a post as the object**, meaning "include me in this thread". Answer that with `Reject` or
ignore it, without an error.
- **Gaps:**
- **P1:** W2 for NodeBB Articles (`privapub.excerpt`).
- **P2:** Groups without `followers`; Announces from an Application; context Move/Remove; paged `context` with ETag;
Friendica's thread-Follow.
### PeerTube 8.3.1 (2026-09-28)
**Emits**
The account sends `Create{Video}`; the channel (a Group) sends `Announce{Video}`, so deduplicate.
| Part of the Video | What it holds |
|---|---|
| Attribution | `attributedTo: [Person, Group]` (both, possibly bare URLs); `audience` = the channel |
| Description | Markdown in `content` with `mediaType: text/markdown`; `summary` is the CW (since 7.2) |
| `url[]` | A `text/html` watch page; per-resolution mp4 Links (`height`, `width`, `fps`, `size`, ffprobe codec types); HLS `application/x-mpegURL` (since 6.3 audio and video can be separate, with "0" as the audio-only resolution); torrent and magnet; a metadata JSON |
| Images | `icon[]`: thumbnails up to 1920 px; `preview`: storyboards |
| Captions | `subtitleLanguage[]` with VTT and HLS URLs |
| Chapters | `hasParts` |
| Playback and metadata | `duration` (ISO 8601), `views`, `state`, `isLiveBroadcast`, `permanentLive`, `latencyMode`, `commentsPolicy`, `downloadEnabled`, `category`, `licence`, `language`, `support`, `uuid`, `embedUrl`, `originallyPublishedAt`, `schedules`, `aspectRatio` |
| Sensitivity | `SensitiveTag` |
- **Value meanings:**
- **Live now** means `isLiveBroadcast && state == 1`.
- `commentsPolicy`: 1 open, 2 closed, 3 needs approval.
- **Other activities:**
- Comments are Markdown Notes.
- `View` comes from the server's Application actor.
- `Dislike`; `ApproveReply` (FEP-5624, since 6.2); `CacheFile` (mirrors); playlists.
**Expects**
- **Replies** must:
- be Public;
- have non-empty `content`, a valid `url` and `published`;
- have an `id` on the actor's host;
- have an `inReplyTo` that resolves to the video or one of its comments.
- `commentsPolicy` 2 rejects replies; 3 holds them until approved.
- PeerTube signs its fetches.
- It drops followers that have been unreachable for about 7 days (8.2).
**What Mastodon does with it:** `<h2>name</h2>` + summary + link. The description is dropped and there is no
attachment. The player is a card whose iframe loads from the remote host, which our proxy rule forbids.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Store the whole Video: variants, thumbnails, storyboards, captions, chapters, duration, live state, views, `commentsPolicy`, licence, category, language, support, channel | P1 | own `privapub.video` |
| Play through the proxy: a `MediaAttachment{type: video}` pointing at a proxied muxed mp4 (a `web-video` file, or a fragmented file whose codec types include both audio and video); `preview_url` = a ~560 px `icon`; `meta.original` with width, height, `frame_rate`, `duration` | P1 | `media_attachments` |
| The proxy answers **Range** requests and rewrites HLS playlists and caption URLs to proxied ones. A 720p file is about 0.9 GB, so stream it; never buffer. | P1 | — |
| A video card made from the object, **without** a remote iframe | P1 | `Status.card{type: video}` |
| Reply rules: closed when `commentsPolicy` is 2; replies sent Public with a `url`; `ApproveReply` shows our reply as pending | P1 / P2 | own |
| Dislike counts; live state through `Update`; chapters and captions | P2 | own |
| Optionally, `View` sent from the instance actor (so it never names a persona) | P3 | — |
### Loops (1.0.0-beta.14) and Pixelfed (0.14.4)
- **Loops:**
- A video is a Note with one mp4 `Document` whose `url` is a string. **The poster is in the object's `preview`.**
Videos are vertical.
- Its interactionPolicy follows GoToSocial's model.
- It sends `QuoteRequest` and `FeatureRequest`.
- A top-level post it accepts must be a Note with an mp4 attachment, from an instance its admin allowlisted, **≤ 100 MB,
checked with a HEAD request**. Our media must answer HEAD.
- **Pixelfed:**
- Posts are a Note with attachments.
- It also sends `location: Place{name, latitude, longitude, country}`, `commentsEnabled`, `capabilities`, and
`canQuote` (0.14).
- Stories are `Add{Story}` with a bearcap only Pixelfed understands.
- Pixelfed 0.14 does FEP-044f and FEP-8fcf.
- **What Pixelfed accepts:**
- Only `Note`s, and **a top-level post must have media**.
- **Every** attachment must be a Document or Image with a string `url` and a `mediaType` in the instance's list. The
default list is **jpeg, png and gif only**, and a single webp or avif attachment rejects the whole post.
- **Gaps:**
- **P1 outbound:** keep JPEG/PNG renditions with an explicit `mediaType`.
- **P1 inbound:** Loops' `preview` poster.
- ~~**P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated~~ done:
`ObjectShapes.NotePlace` keeps a `Place` with coordinates (Pixelfed sends them as strings) in `Post.Place`, an edit
replaces it, and nothing renders it back out.
- **P2:** `commentsEnabled: false` disables replies.
- **P3:** ignore `Add{Story}` without an error; answer `FeatureRequest` with `Reject`.
### Long-form: WordPress plugin 9.3.1, Ghost 6, WriteFreely 0.17.2
FEP-b2b8 (draft) describes the shape: plain-text `name`, a `summary` teaser (≤500), full HTML `content`, `image`, and
a `preview` Note fallback.
- **WordPress:**
- Object type: an Article for a titled post, a Page for a page, otherwise a Note.
- Fields: `image` (the featured image), `preview`, `interactionPolicy.canQuote`.
- A CW is `sensitive` + `summary` + `dcterms:subject`.
- `id` is `?p=123`, different from `url`.
- The blog actor is a Group with `attributionDomains`.
- It sends an `Update` on every save, and **signs with RFC 9421 first** (9.3.0), falling back to draft-cavage after
any 4xx.
- It drops followers-only replies.
- **Ghost 6** (its ActivityPub service is separate, built on Fedify):
- Article with `image` as a bare string and `preview`; members-only parts removed.
- It refetches every object signed, **never applies remote Updates**, and accepts only Note and Article.
- Public is addressed as `as:Public`.
- **WriteFreely:** Article when the body has a paragraph break. **`preview` reuses the Article's id**, so never store
it as its own post. It has no comments.
- **Gaps:**
| Gap | P | Client surface |
|---|---|---|
| An Article shown in the Mastodon API: `content` = name + teaser (`summary`, else `preview.content`) + a link to `url`; a card made from the object (title, description, `image` then `icon`, author, provider, date) | P1 | `Status.content`, `Status.card` |
| The full sanitised HTML kept for a reader view | P1 | own `privapub.article{title, html, cover, excerpt}` |
| Body images duplicated in `attachment` removed; `attributedTo` arrays resolved to the Person | P2 | — |
### Events: Mobilizon 5.2.4, Gancio 1.28, Friendica/Hubzilla/Forte
FEP-8a8e (draft) is the common reference.
- **Mobilizon:**
- Times and status: `startTime`, `endTime`, `timezone`, `status`/`ical:status`, `isOnline`, `draft`.
- Place: `location: Place{address: PostalAddress, latitude, longitude}`.
- Participation: `joinMode`, `participantCount`, `maximumAttendeeCapacity`, `remainingAttendeeCapacity`,
`anonymousParticipationEnabled`.
- Comments: `repliesModerationOption`, `commentsEnabled`.
- Other: `category`, `contacts`.
- Attachments: the online link `Link{name: Website}`; `PropertyValue`s under `mz:` keys; a banner `Document`.
- The event is attributed to the Group.
- **RSVP:** `Join{object: event}` with a stable id that can be fetched; Mobilizon answers `Accept` or `Reject`;
`Leave`.
- **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP.
- **Friendica, Hubzilla:** RSVP with `Accept`/`Reject`/`TentativeAccept`. Hubzilla creates events as `Invite{Event}`,
with HTML in `location.content`, and `-00:00` for floating times.
- **FEP-8a8e:** a server that does not handle joins answers `Join` with `Ignore`.
- **Gaps:**
| Gap | P | Client surface |
|---|---|---|
| Store times, time zone and place in every one of those shapes; RSVP routing (W6); `Invite{Event}`; answer `Join` with `Ignore` until RSVP exists | P1 | `content` = title + "date (zone) · place" + link; card with the banner |
| Capacity, join mode, online link, status, category | P2 | own `privapub.event` |
| RSVP out (`Join`/`Leave`, stable ids), from the persona the user chose | P2 | own `…/rsvp` |
### Audio: Funkwhale 2.0.11, Castopod 1.15.5, Owncast 0.3.0
- **Funkwhale:**
- `Create{Audio}` whose `url` is an array: a page link plus the stream, with `bitrate` and `size`.
- Also `duration`, `position`, `disc`, `album`, `license` and `image`; `summary` is a hashtag line (W2).
- `Listen{Track}`.
- **Castopod:** an episode arrives as a link-only Note; the player comes from OpenGraph/oEmbed. Fetching the episode
with the podcast type gives a `PodcastEpisode` with transcript and chapters.
- **Owncast:** a `Service` actor; "go live" is a Note with a thumbnail.
- **Gaps:**
- **P1:** pick the audio Link out of `url`, sniff its type, apply W2. Surfaces as `MediaAttachment{type: audio}`
with `meta.original.duration` and the cover as `preview_url`.
- **P2:** duration, cover, album, position and licence (own `privapub.audio`); the Castopod transcript and chapters.
- **P3:** `Listen` history; Owncast live state.
### Books: BookWyrm 0.9.3, NeoDB 0.19.4
- **BookWyrm:**
- **To non-BookWyrm servers** it sends a review as an `Article` named `Review of "Title" (★★★★): …`, a comment or
quotation as a Note with the citation appended, and the cover as a `Document`.
- The fields `inReplyToBook`, `rating` and `quote` go only to BookWyrm servers.
- **NeoDB:** `relatedWith[]` carries the item, the rating (out of 10), the review and the shelf; `tag[]` holds catalogue
items (`{type: "Movie", href, name, image}`).
- **Gaps:**
- **P1:** keep the Article `name`, `tag` entries that are not Mention or Hashtag, and covers that have no blurhash.
- **P2:** keep `relatedWith`, `rating` and `inReplyToBook` raw; a card for the item; own
`privapub.review{item, rating, scale}`.
### Threads, Flipboard, Bluesky via Bridgy Fed
- **Threads:**
- **Unsigned GETs answer 404**, so fetch it signed, as our instance actor.
- Quotes arrive as `_misskey_quote` + a FEP-e232 tag + an `RE:` fallback.
- Polls are not federated.
- Threads users must be 18 or over, opt in, and live outside the EU.
- Threads blocks servers that ignore deletes or have no privacy policy.
- **P1:** process `Delete` promptly; publish a privacy policy and a minimum age.
- **Flipboard:**
- Each flip is a link-only Note: headline, a link with `utm_*` parameters, and a Mention of the magazine.
- Magazines are Groups that `Announce` bare URIs.
- **P1:** fetch objects that arrive as bare-URI Announces (we do).
- **P2:** a card, or the post reads as a bare headline; deduplicate by canonical URL with `utm_*` stripped.
- **Bridgy Fed:**
- Actors are `https://bsky.brid.gy/ap/did:plc:…`, with handles like `@x.bsky.social@bsky.brid.gy` (**dots in the
user part**). `alsoKnownAs` holds `did:` values; there is no `published` and the outbox is empty.
- Posts: a `url` array with an `at://` canonical link (W1); **media without `mediaType`** (W4); link embeds flattened
into `content`; video thumbnails in `image`; long-form as Article.
- **Bridging a persona:**
- opt-in, by following the bot;
- the persona needs an `icon` and must be at least 7 days old;
- only public posts are bridged;
- **opting out takes a `Block` sent to the bot**, which we never send today.
- **Privacy note:** two personas created on the same day share the same day-truncated `published`. That is weak,
but a link.
- **P2:** `Account.created_at` fallback; the Bridgy opt-out Block (see §6).
## 4. What the rich client needs from the server
The rule: **never drop what arrived; keep it typed where we understand it and raw where we don't.** The Mastodon API
keeps working for existing apps. Everything it cannot express goes under a `privapub` object on the same entities,
following the precedent of `pleroma.*` and `akkoma.*`, plus a few endpoints of our own.
### 4.1 One post, many kinds
`Post` gains a `Kind` and one typed payload per kind.
| Kind | From | Typed payload | Mastodon API view | `privapub.*` |
|---|---|---|---|---|
| note | everyone | — | as today | `source` (Markdown/MFM/BBCode), per-attachment `sensitive` |
| article | WordPress, Ghost, WriteFreely, NodeBB, BookWyrm, Bridgy | title, excerpt, cover, full HTML, `preview` | content = title + excerpt + link; card from the object | `article` (reader view) |
| video | PeerTube, Loops, Bridgy, Owncast | variants, HLS, poster, storyboards, captions, chapters, duration, live state, licence, channel | proxied `video` attachment + card | `video` |
| audio | Funkwhale, Castopod | stream variants, cover, duration, album, position, licence, transcript, chapters | `audio` attachment | `audio` |
| event | Mobilizon, Gancio, PieFed, Friendica, Hubzilla, CherryPick | start, end, zone, place, online link, join mode, capacity, status | text + card | `event` (+ RSVP) |
| poll | Mastodon, Misskey, Pleroma, GoToSocial, PieFed | options, counts, multiple, end, closed, voters | `poll` | — |
| link | Lemmy, PieFed, Mbin, Flipboard, Mastodon 4.7 | href, thumbnail, alt, author | `card` | `link` |
| review | BookWyrm, NeoDB | item, rating, scale, shelf | text + card | `review` |
| page / thread | Lemmy, PieFed, Mbin, NodeBB | title, community, flair, lock, removal, score | content + `card` for links | `thread{community, flairs, locked, removed, score, distinguished}` |
These cut across every kind:
| Feature | Mastodon API | `privapub.*` |
|---|---|---|
| Quote | `quote{state, quoted_status}` | the URI when it could not be fetched |
| Emoji | `emojis` | — |
| Reactions | `emoji_reactions` (and `pleroma.emoji_reactions`) | — |
| Votes | — | `vote{score, up, down, mine}` |
| Interaction policy | GoToSocial-style `interaction_policy` | — |
| Edit history | `edited_at`, `/history` | — |
### 4.2 Linking things together
Every reference is kept as a URI even when the target could not be fetched: the parent, the quoted post, the
community, the channel, the book, the original `url`. The client can then link out where it cannot embed. This
follows the `akkoma.in_reply_to_apid` / `akkoma.quote_apid` precedent.
A link opens `url` (W7), never `id`. Media and thumbnails only ever go through the proxy, so the client never contacts
a remote host.
### 4.3 Details view ("nerd stats")
`GET /api/privapub/v1/statuses/:id/provenance` (and the same for accounts):
| Group | Fields |
|---|---|
| Raw | The object exactly as received, with its hash. Every later refetch and `Update`, with timestamps. The `@context` as sent (the namespaces show `toot`, `misskey`, `litepub`, `lemmy`, `pt`, `mz`, `gts`, `fedibird`, …). |
| Path in | How it arrived: direct Create, inbox forward, Announce (and by whom: community, channel, magazine, relay), backfill, or fetch on demand. Delivered to the personal or the shared inbox. |
| Trust | Signature scheme: draft-cavage (algorithm string, signed headers, query signed or not), RFC 9421, FEP-8b32 proof, or verified by refetch from origin. Key id and key type. LD signature present but ignored. |
| Time | `published`, `updated`, received, and their gaps (backdated posts, clock skew). |
| Extensions | Detected from properties: 044f `quote`, `interactionPolicy`, 7888 `context`, 8b32 `proof`, 521a `assertionMethod`, 8967 Link attachment, c16b `htmlMfm`, `_misskey_*`, `searchableBy`, `formerRepresentations`, FEP-1b12 `audience`. |
| Origin | Software and version from NodeInfo, with a family. This is for display only: FEP-0151 says the names are opaque, so never branch on them except where a peer does the same to us (PieFed). |
| Media | Variants with codec, fps, size and bitrate; infohashes, magnets and mirrors; licence. |
| Counts | Remote likes, boosts, views, downloads and dislikes as last seen, with the time. |
| Interactions | Policy as received, defaults applied, approval state, authorization URIs. |
| Moderation | Removals, locks and bans as received from a community, with reasons. |
`GET /api/privapub/v1/instances/:host` returns cached NodeInfo (`metadata` extras: Misskey `themeColor`/`maxNoteTextLength`,
Pleroma `features[]`/`federation.mrf_policies`), the instance API's icon and description, the delivery health our
circuit breaker sees, and which signature scheme worked. Its `geo` is the public projection of where the server is
(`precision` `city`, `country` or `cdn`, ROADMAP owner decision on server locations); a CDN-fronted server's names the
CDN's domain, how it was found (`ranges`, `headers`, `asn`) and `before_cdn`, its last place before the CDN. `?host[]=`
answers up to 40 at once, and this server describes itself the same way. `/instances/:host/history` lists its weekly
snapshots; `/api/privapub/v1/cdns` and `/cdns/:domain` group servers by CDN, week by week.
`software.name` → family, for display:
| Family | `software.name` values |
|---|---|
| Mastodon | mastodon (glitch-soc shows `+glitch` in the version), hometown, fedibird, kmyblue |
| Misskey | misskey, sharkey, cherrypick, iceshrimp, firefish, foundkey, catodon |
| Pleroma | pleroma, akkoma |
| Microblog | gotosocial, takahe, hollo, mitra, snac, smithereen, ktistec, wafrn, bonfire, friendica, hubzilla, streams/forte |
| Threadiverse | lemmy, piefed, mbin, kbin, lotide, nodebb, discourse |
| Media | peertube, pixelfed, loops, funkwhale, owncast, vernissage, castopod |
| Publishing | wordpress, ghost, writefreely, plume |
| Events | mobilizon, gancio |
| Other | bookwyrm, neodb, forgejo |
| Bridges and relays | bridgy-fed, activityrelay |
## 5. Signatures, identity and transport
| Topic | State on 2026-10-01 | PrivaPub | P |
|---|---|---|---|
| RFC 9421 inbound | Mastodon accepts since 4.5. WordPress and Fedify sign with it first. GoToSocial, the Misskey family, Akkoma, Pleroma and Bridgy do not. | Verify RSA and Ed25519; `content-digest` (RFC 9530); one signature; `created` and `keyid` | P2 |
| RFC 9421 outbound | Mastodon 4.7 double-knocks | draft-cavage first; RFC 9421 after a 401; remember per host | P2 |
| 400 vs 401 | A 400 or 401 makes Mastodon 4.7 and WordPress retry with the other scheme | 401 only for signature failures (we do this); 400 only for bodies that are really malformed | P1 (keep) |
| Temporary key failure | Mastodon answers 503 | We should answer 503 too, and treat a 503 as a retry in delivery | P2 |
| Keys | `publicKey` can be an array (Mastodon 4.6). FEP-521a `assertionMethod` Multikey is FINAL (Ed25519 `z6Mk…`). GoToSocial key ids have no `#` and point at a stub. | Read all of these | P2 |
| Integrity proofs | FEP-8b32 `eddsa-jcs-2022`: JCS, no JSON-LD. Sent by Mitra, Streams, Hubzilla, Fedify and others; Mastodon verifies them from 4.7 | Verify, so relayed or forwarded objects need no refetch | P2 |
| LD signatures | Mastodon still sends `RsaSignature2017` | Ignore, and refetch from origin (we do) | — |
| Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 |
| `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 |
| Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 |
| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. | P2 |
| Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 |
| Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 |
| FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 |
| Search consent | `indexable` (missing = false), `discoverable`, `searchableBy` (FEP-268d, which takes precedence) | Honour all three; emit explicit `false` per persona | P2 |
## 6. Choices the privacy design has to make
These change what PrivaPub reveals, so they are the owner's calls, not implementation details. **All six were decided on
2026-10-01; the decisions are in `docs/ROADMAP.md`, "Owner decisions on what PrivaPub reveals".** In short:
1. pages are fetched by the server for public posts;
2. blocks federate;
3. website authorship stays off;
4. PeerTube views are never sent;
5. Bluesky bridging is per persona, with persona dates randomised;
6. a one-time notice before the first reaction or vote.
The options as they were laid out:
1. **Link previews.**
- Building a card from the object, or from FEP-8967 `preview` data, fetches nothing; we do that.
- Fetching the linked page tells that site our server read the link. Cache per URL across personas, so no fetch
ties a page to one persona; add jitter.
- Proposed admin switch: *off / from the object only / fetch*.
2. **Outbound Block.**
- Blocks are never sent today.
- Bridgy Fed's opt-out *requires* one, and Mastodon, GoToSocial and Misskey federate blocks as a matter of course.
- Proposal: never by default; an explicit per-persona "tell their server" option.
3. **`attributionDomains` / `fediverse:creator`.** It ties a persona to a website. Opt-in per persona only.
4. **PeerTube `View`.** Counting a view tells the origin a video was watched. If ever, send it from the instance actor.
5. **Bridging to Bluesky.** It is per persona; each persona needs its own avatar and 7 days of age. Same-day personas
share a `published` day.
6. **Emoji reactions and votes** are public by nature on every platform that has them. The client should say so
before the first one.
## Sources
The full source lists, with versions and dates, were gathered by five research passes on 2026-10-01. The primary
ones:
| Area | Sources |
|---|---|
| Mastodon | `CHANGELOG.md` (4.3.0–4.7.2); docs.joinmastodon.org (`spec/activitypub`, `spec/security`, `spec/webfinger`, `client/quotes`, `client/collections`, entities); the `main` source (`note_serializer.rb`, `activity/{create,update,accept,delete,quote_request,move,like,flag}.rb`, `status_parser.rb`, `media_attachment_parser.rb`, `fetch_link_card_service.rb`, `verify_quote_service.rb`, `fetch_all_replies_service.rb`, `signed_request.rb`); developer posts for 4.5, 4.6 and 4.7; GHSA-vwhj, GHSA-rwcw, GHSA-vg36; issue #39997 (spam, July 2026) |
| GoToSocial | Codeberg releases 0.18.0–0.22.1; `docs/federation/*` (`interaction_controls`, `posts`, `actors`, `http_signatures`, `access_control`); `internal/typeutils`, `internal/ap`, `internal/federation`, `internal/transport`; issues #4939, #1894, #4994, #4849, #4677 |
| Misskey family | misskey-dev/misskey develop `ed9654b` (`ApRendererService`, `ApInboxService`, `ApNoteService`, `ApAudienceService`, `ReactionService`, `check-against-url`); PR #16250; Sharkey develop `effcecb`; Iceshrimp.NET dev `926fcde` (`FEDERATION.md`, `LdHelpers.cs`, `HttpSignature.cs`, `NoteRenderer.cs`); CherryPick develop |
| Pleroma, Akkoma | Pleroma develop `cfeca7f` (`transmogrifier.ex`, `inbox_guard_plug.ex`, `ap_extensions.md`); Akkoma develop (`FEDERATION.md`, `CHANGELOG.md`, `nodeinfo_extensions.md`) |
| Lemmy | LemmyNet/lemmy `main` `f1476db` and tag 0.19.20 (`crates/apub/apub/assets/*` fixtures, `objects/src/protocol/*`, `activities/src/protocol/*`, the pending-post migration); PRs #5856, #6152, #6401, #6409, #6466; issues #6281, #6343, #6346; activitypub-federation-rust `fetch/mod.rs` |
| Other threadiverse | PieFed `41cccb7` (`FEDERATION.md`, `docs/activitypub_examples`, `docs/fep-1a11.md`); Mbin `cf95b04` (`docs/05-fediverse_developers`); NodeBB `260a0cc` (`src/activitypub/*`); discourse-activity-pub; friendica `FEDERATION.md` |
| PeerTube | docs.joinpeertube.org/api/activitypub; Chocobozzz/PeerTube develop (`object-to-model-attributes.ts`, `custom-validators/activitypub/*`, `process-*.ts`); live fetches from peertube2.cpy.re, garr.tv (8.2.4), tube.tchncs.de |
| Pixelfed, Loops | pixelfed dev (`Transformer/ActivityPub/Verb/*`, `Inbox/HandlesCreates.php`, `Helpers::verifyAttachments`); loops-server main (`FEDERATION.md`, `NoteWithVideoAttachmentValidator.php`) |
| Long-form, events, audio, books | wordpress-activitypub trunk (`class-post.php`, `class-signature.php`); TryGhost/ActivityPub v1.2.13; writefreely `posts.go`; Mobilizon 5.2.4 converters; Gancio 1.28.2/2.0-beta; Funkwhale 2.0.x; Castopod 1.15.5; Owncast 0.3.0; BookWyrm 0.9.3; NeoDB `docs/internals/activitypub.md` |
| New entrants | fed.brid.gy/docs and bridgy-fed `activitypub.py`; live probes of threads.net, flipboard.com and bsky.brid.gy; engineering.fb.com (2024-03-21) |
| FEPs | codeberg.org/fediverse/fep at 2026-09-30: 044f, 1311, 1a11, 1b12, 2345, 268d, 2c59, 4f05, 521a, 5624, 5feb, 7458, 7628, 7888, 7aa9, 844e, 8967, 8a8e, 8b32, 8fcf, 9098, 9967, ae0c, b2b8, c0e0, c16b, d556, e232, ef61, f15d, f228, fb2a, fe34 |
| Signatures | SWICG "ActivityPub and HTTP Signatures" report and its RFC 9421 adoption tracker; SocialHub "RFC 9421 HTTP signatures in 2026" (Jan 2026) |