Files
SocialPub/docs/INTEROP.md
T
thepraandClaude Opus 5.5 d9fb5c582f Where a server is, on the public instance API
/api/privapub/v1/instances/:host gains `geo`, the public projection of a
server's place already decided for public server locations
(PublicGeo.Project): city, coordinates and network for servers reporting
at least 10 users and not behind a CDN, the country otherwise, the CDN's
name for a CDN-fronted one, with DB-IP's attribution. `?host[]=`
answers up to 40 servers at once, and this server describes itself:
its host's address located once a day (SelfLocation), or
Statistics:Geo:Self when the owner sets it.

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

63 KiB
Raw Blame History

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 Notes;
  • 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 Events;
    • 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 Groups 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 Notes, 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; 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}; PropertyValues 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); ?host[]= answers up to 40 at once, and this server describes itself the same way.

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)