Files
SocialPub/docs/INTEROP.md
T
thepraandClaude Opus 5.5 b39eadffeb Hubzilla in the pasture
Hubzilla 11.4.1 with its pubcrawl addon (peers/hubzilla.sh): the hlhd image on the shared MySQL, a cron sidecar, hzuser
and hzfriend made through Hubzilla's own PHP as public channels that speak ActivityPub. scenarios/hubzilla.sh, 20
checks: follows, posts, comments, likes, edits and deletions both ways, and a third channel's comment that the thread's
owner passes on, which PrivaPub takes on its FEP-8b32 proof. Known gap G-0010 (upstream): Hubzilla 11 keeps a follower
after its Undo{Follow}.

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

96 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); move each persona's follow, lists, mutes and blocks (done 2026-10-05) P1 Account.moved
Re-run WebFinger when preferredUsername changes; key accounts on the actor id P2 Account.acct
Inbound Block: stop delivering, hide (done 2026-10-04) P2 relationship.blocked_by
Add/Remove featured (pins, tags): pins both ways and the collection read daily, done 2026-10-05; featured tags open P2 GET /accounts/:id/statuses?pinned=true
Remote like and boost totals P2 counts
Read the thread's context collection to complete a thread (done 2026-10-05, FetchReplies) P2 /statuses/:id/context
Publish context and a paged replies P2 —
FeatureRequest: send Reject (or implement FEP-7aa9) P3 —

Pasture evidence (2026-10-05, Mastodon v4.7.3, tools/pasture/scenarios/mastodon.sh): 57 checks pass, and the scenario can be run again on the same pasture (it undoes the lock and the block it leaves). They cover:

  • discovery, follows and locked follows both ways;
  • public, CW and followers-only posts (the last answering 404 unsigned);
  • replies threading both ways;
  • an outsider's reply to an account a persona follows, which Mastodon delivers to that account's followers, kept (it was dropped as unaddressed until 2026-10-05);
  • a thread completed from Mastodon's FEP-7888 context: the outsider's answer to their own reply, never delivered, joins the thread once a persona opens it;
  • 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, block and unblock both ways (Mastodon's block and its undo reach our blocked_by);
  • 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 enforced since 2026-10-04 (BlockHandler, BlockedBy): follows end both ways, the blocker is hidden from the persona, and the relationship says blocked_by.

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.
  • It can lose a status from its cache (seen once, town 2026-10-05): it looks an Update's object up by URI, and when that fails it handles the Update as a Create, which changes nothing for a status it already has. One followers-only post kept its first text through three Updates, each logged as activityType=Create, though its row was there; after a restart the same Update applied. Fresh posts, liked or not, were edited at once.

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 done 2026-10-05 P1 GoToSocial-style Status.interaction_policy
Send ReplyRequest/LikeRequest where approval is needed; handle Accept{result}/Reject; attach the authorization done 2026-10-05 (InteractionApprovals), AnnounceRequest too P1 privapub.approval: pending/rejected on our own reply or boost
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. On 2026-10-05 the scenario runs 64 checks, all passing, and can be run again on the same pasture (the locked persona loses its follower first). Nine of them are interaction policies: gtsuser's post asks before anyone but its author replies or likes and lets nobody else boost it; PrivaPub shows the policy, refuses the boost, sends alice's reply and like as a ReplyRequest and a LikeRequest, and once gtsuser approves them the reply threads under the post and the like counts. 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.

Account moves (2026-10-05, tools/pasture/scenarios/moves.sh): GoToSocial moves an account through its API (the new one names the old in alsoKnownAs first, /api/v1/accounts/alias) and sends Move{object: old, target: new} to the old one's followers. PrivaPub reads the new account again, finds the old one among its aliases and shows the old account moved. Since the owner's decision of the same day it also moves alice's follow, as Mastodon does: a Follow to the new account (locked by GoToSocial, which approves it) and an Undo to the old; 8 checks pass. PrivaPub dropped Move as an unknown type until then.

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, for comments as for posts (Akkoma and Mastodon drop the activity form too; the town found Akkoma members missing them).
  • 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.

Misskey never lowers a post's renoteCount when a renote is deleted, so a remote boost undone with Undo{Announce} still counts there (G-0005; its NoteDeleteService lowers only repliesCount). Sharkey lowers it. It also counted a boost twice when the same Announce reached it twice at once, which PrivaPub no longer does: a boost goes to the author's server once, through its shared inbox.

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.

Town evidence (2026-10-05, Iceshrimp.NET 2026.1.2-beta, town.sh seed iceshrimp-pair): 225 cells pass between two personas and three Iceshrimp accounts (AuthorizedFetch on): delivery and confinement at every visibility both ways, likes, boosts, emoji reactions and votes counted, threads, edits and deletes, follows of locked accounts on both sides, and the privacy rows. Two cells are Iceshrimp's own gap (G-0004): a new note goes to its author's followers only, so an account it mentions or answers gets it once it is edited, not when it is written (the Create's pre-deliver job lists no recipients, through the Mastodon and the native API alike). Its followers and following collections answer HTML to an ActivityPub Accept, so PrivaPub shows no counts for them.

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.

Town evidence (2026-10-05, Pleroma 2.10.2, town.sh seed pleroma-pair): 241 cells pass between two personas and three Pleroma accounts: delivery and confinement at every visibility both ways, likes, boosts, reactions and votes counted, threads, edits and deletes, follows of locked accounts, and the privacy rows. Its first run found two things:

  • A Follow taken and then lost. Pleroma answers an inbox POST 200 and checks the signature in a worker; while it could not fetch our actor, it dropped our Follows there, and they stayed pending on our side for good. A pending request is now sent again when the persona follows again (FEDERATION.md, Delivery).
  • Duplicates are refused, harmlessly. A Create whose object Pleroma already holds (fetched for a reply, or delivered to another inbox first) is cancelled with "The object to create already exists".

Pleroma federates through hackney, which trusts certifi's compiled-in roots, never a file: the pasture's image names the system bundle in its config.

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. Votes and their undoing done 2026-10-04 (AnnounceHandler.Relayed: trusted from the community's own server or on its own posts, otherwise fetched from the voter's origin); moderation still open 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 to someone who writes to us that way, and to Lemmy 0.19 and Mbin by NodeInfo, done 2026-10-05) 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. On 2026-10-05 the scenario runs 29 checks, all passing: the relayed votes, and a moderator's lock (refusing our reply, then lifted), a ban of alice (blocked_by on the community, refusing her reply, then lifted) and a removal. The 2026-10-03 run covered:

  • 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.
  • It keeps the ids of the activities it received and never answers one again. A follow it took and whose Accept it lost (sent before its worker for our server started) stayed pending through two resends of the same Follow; sent under a new id (2026-10-05), it was accepted at once (the village's community follow, pending since 05:06).
  • Votes travel only to the community, which relays them as Announce{Like} and Announce{Dislike}: counted since ed08f80, and a vote turned the other way replaces the first since bbeeda7. A moderator's removal, lock and ban come the same way and are applied since 2026-10-05 (the removal was applied all along; the scenario gave up before Lemmy's 30-second batch).
  • 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's release trusts only the roots its rustls bundles, never Caddy's CA, so the pasture builds 0.19.20 from its tag with reqwest's rustls-tls-native-roots added (tools/pasture/images/lemmy19) and runs it as lemmy19.test.
  • 0.19 takes private messages only as ChatMessage (ChatMessageType has no Note) and answers our direct Note 400. Its actors don't say so, so since 2026-10-05 (owner decision) a direct message to one account on a server whose NodeInfo names Lemmy before 1.0 goes out as a ChatMessage, a first message too. Most of the threadiverse runs 0.19, so this matters more than Mbin's same rule.
  • Pasture evidence (2026-10-05, Lemmy 0.19.20, tools/pasture/scenarios/lemmy19.sh): 30 checks pass: communities both ways, threads with titles, comments both ways, votes up and down both ways (relayed in the community's announces, as 1.0 does), its private message to alice and her answer as a ChatMessage (another persona's too), a first message to an account that never wrote here, a moderator's lock (replies then refused), unlock, ban and unban (blocked_by) and removal, statistics.

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.
    • A magazine's threads go to its subscribers as their author's Create, to and audience naming the magazine, never announced by it. PrivaPub dropped them as addressed to nobody here until 2026-10-05; a post whose group is followed here and lives on the post's server is now kept (FEDERATION.md, Groups).
    • A moderator's lock is a bare Lock (and Undo{Lock}) from the moderator, not inside an announce; taken since 2026-10-05 from the post's own server. A removal ("trash") is a Delete from the moderator, believed once Mbin answers the thread gone.
    • Votes: an upvote is an Announce, a favourite a Like. Downvotes stay on Mbin (no Dislike), and taking an upvote back sends nothing (its vote listener announces only the upvote), so a boost counted here stays.
    • Private messages only as ChatMessage: a direct Note is dropped ("PM: not implemented"), and Mbin's actors say nothing that would let a sender choose, so PrivaPub sends a ChatMessage to a server whose NodeInfo names Mbin (owner decision 2026-10-05). Its API never starts a conversation with a remote account.
    • Mbin addresses a private message to the recipient's profile page (apPublicUrl, the actor's url), not to its id. PrivaPub takes a persona's profile page as its address since 2026-10-05. Mbin also passes on a message it received as though it were sending it, signed as its remote author; it has no key for that account, so the delivery fails on Mbin's side and nothing leaves.
    • Mbin names what it makes during a request after the request's host, port included.
    • Pasture evidence (2026-10-05, Mbin 1.10.1, tools/pasture/scenarios/mbin.sh): 26 checks pass: magazines both ways; an Mbin thread in our community arrives titled and ours reaches Mbin as a thread; a magazine's thread reaches alice's home and her Note becomes a microblog post in it; comments both ways; Mbin's favourite counts as a like and its upvote as a boost, alice's like and boost count there; alice's first message and mbuser's answer; a moderator's lock, unlock and removal; the unfollow; statistics.
  • Gaps:
    • P1: Lemmy's P1 set, W2, and tolerating source as a string.
    • P2: galleries; post Move; repliesEnabled; nsfl; flairs; publish likes/shares totals.
    • P3: genAI; accepted answers; batched Announces.
  • PieFed sends a community's announces to the inbox of the Application at a peer's root (Lemmy's site actor), and to https://<host>/inbox when the root answers anything else. PrivaPub answered 404 there until 2026-10-05, so every announce went to a /inbox it does not have; it now serves its instance actor at / for ActivityPub requests.
  • PieFed keeps serving a thread its moderator removed (200, the Page unchanged), so a removal could not be checked against the post's origin; a community on the post's own server is now believed at once (FEDERATION.md, Groups).
  • Pasture evidence (2026-10-05, PieFed 1.7.17, tools/pasture/scenarios/piefed.sh): 29 checks pass: communities both ways (follows accepted); a PieFed thread in our community arrives titled and ours reaches PieFed through its announce; a PieFed community's thread reaches alice's home and her thread lands in it; comments both ways; PieFed's upvote counts as a like and its change to a downvote is applied, alice's like is an upvote there; a community poll arrives as a poll and alice's vote counts on PieFed; private messages both ways; a moderator's lock (replies then refused), unlock and removal; the unfollow; statistics.

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).
    • Chats are private Notes, threaded by inReplyTo into the room they answer.
    • It never federates a topic's lock (its activitypub/out.js has no Lock), and its API follows an account elsewhere only when named by its handle (PUT /api/v3/users/<user@host>/follow).
    • Pasture evidence (2026-10-05, NodeBB 4.16.1, tools/pasture/scenarios/nodebb.sh): 23 checks pass, with no change to PrivaPub: alice follows a category and its topic reaches her as a titled thread; replies both ways; nbuser follows alice and her post reaches NodeBB; nbuser's upvote counts as a like and taking it back too, alice's like is an upvote there; an edit and a deletion; a chat both ways; the unfollow; statistics.
  • 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.
    • It forwards every activity in its threads (comments and their deletions, other servers' included) to the thread's followers, signed with the thread owner's key. PrivaPub answered them 401 until 2026-10-05; it now takes a forwarded Create or Update as the object reads at its origin, and a Delete once the origin says it is gone (Forwarded).
    • An edit made through its Mastodon API never federates: Statuses::put leaves edited alone, and only a changed edited notifies. Its web editor's edits do.
    • Its own accounts' actors are cached only once something reads them. A Follow that reaches one first makes it fetch the account from itself, signed as that account, and checking that signature builds the actor again: the request recurses for about five minutes and holds the sender's Follow past a 15-second timeout (PrivaPub's retry gets it through). Seen on a fresh install in the pasture, 2026-10-05.
    • An incoming boost is kept with ActivityStreams' verb (as#Announce); its own are /share.
    • Its activity ids are uniqid(), a three-character prefix and the microsecond: two of its processes answering two follows at once gave both Accepts one id (town, 2026-10-05). PrivaPub queues an id that comes back carrying another activity apart, so the second follow is not left pending.
    • It counts a follow as made before the Accept when the target already follows its account (its "friend" relation): its API answers following: true at once, and it shows the target's followers-only posts to that account while a locked PrivaPub persona still holds the request (they reach Friendica's shared inbox for the persona's accepted followers there, and Friendica hands them out by its own relations). PrivaPub refuses that account's likes on them until the persona accepts. Town, 2026-10-05.
    • It shows a reply to an account only inside a thread that account holds, and counts on the thread's owner to relay the replies in it: a followers-only reply under a post the account cannot see never reaches it, from any server.
    • Its "automatic friend" page type follows every follower back; the pasture's accounts are "soapbox" pages, which take followers without asking and follow nobody back, as an unlocked Mastodon account does. Its Mastodon API makes them so from locked, read as a number: locked=true unlocks an account.
    • Pasture evidence (2026-10-05, Friendica 2026.05, tools/pasture/scenarios/friendica.sh): 25 checks pass, from a clean install. Follows and unfollows both ways; posts both ways, a titled one arriving with its title; comments both ways, threaded; likes both ways, Friendica's dislike as a downvote, alice's boost counted; edits and deletes both ways; statistics.
  • 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.
    • P1: forwarded activities refused with 401 done 2026-10-05 (Forwarded).

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 —

Pasture evidence (2026-10-05, PeerTube 8.3.1, tools/pasture/scenarios/peertube.sh): 24 checks pass:

  • a persona finds a channel (a group account) and its owner's account, and follows the channel (accepted at once);
  • a new video comes as the channel's Announce, not a Create, so it reaches the persona's home as the channel's boost of the video, with a playable attachment served through the media proxy, which answers byte ranges;
  • a reply to the video is a comment on PeerTube, and an answer to that comment threads under the video here;
  • a like and its undo count on PeerTube; the video's renaming (Update) and its deletion reach us;
  • the unfollow, and statistics.

PeerTube's own instance account announces each new video too, which we drop: nobody here follows it. Its users can follow only PeerTube-like channels and accounts, never a persona. It checks the Host header against its own name, without a port, before it gives out its OAuth client.

Vernissage 1.43.0

  • Photo sharing: a top-level post carries its photos (Image, with alt text, and EXIF in its API); comments are plain replies. It names a PNG image/jpeg in its attachment.
  • It federates only with its queues on (Redis); its API answers 400 to an attachment's metadata unless the whole attachment comes back with it, and takes a new status from the same account only every 40 seconds or so.
  • Pasture evidence (2026-10-05, tools/pasture/scenarios/vernissage.sh): 19 checks pass, with no change to PrivaPub: follows both ways; a photo with its alt text reaches alice; her like, boost and comment land there; her photo reaches the admin's home and its like and comment come back; a deletion; the unfollow; statistics.

BookWyrm 0.9.3

  • Its statuses are Review, Comment and Quotation about a book (inReplyToBook) for other BookWyrms, and "pure" Articles and Notes (the book named in the text) for everyone else, chosen by the recipient server's NodeInfo: PrivaPub gets the pure ones.
  • It keeps nothing of others' but what concerns a book or one of its accounts (a reply to its statuses, a mention).
  • WebFinger finds a local user only under the username its signup gives it, localname@domain.
  • It took the same post twice when one copy reached its shared inbox and another the named account's inbox at once. Since 2026-10-05 PrivaPub delivers once a server, to its shared inbox, as Mastodon does.
  • Pasture evidence (2026-10-05, tools/pasture/scenarios/bookwyrm.sh): 20 checks pass: follows both ways; a review, a comment and a quotation reach alice; her like, boost and reply land there; her post naming bwuser lands, and bwuser's like and reply reach her; a deletion; the unfollow; statistics. BookWyrm has no client API, so the scenario acts in its Django shell, as its views do.

Ghost 6.67 and its ActivityPub service 1.2.14

  • A publication is one actor, @index@<site> (/.ghost/activitypub/users/index), served by a separate service (Fedify) that Ghost's proxy sends /.ghost/activitypub/*, WebFinger and NodeInfo to. Posts go out as Articles with their title; the "Network" screen follows, likes, reposts, replies and posts notes. It signed with draft-cavage here.
  • The service names a site after the request's host and reads its keys from https://<host>/ghost/.well-known/jwks.json, so a request with a port in its Host is refused (JWKS_MISSING). Ghost sets up the webhooks that publish its posts only when it starts with an owner and the service knowing the site; a fresh install has to start again once.
  • Pasture evidence (2026-10-05, tools/pasture/scenarios/ghost.sh): 22 checks pass, with no change to PrivaPub: follows both ways; the publication's post reaches alice as a titled Article, edited and deleted; alice's like and boost count there; Ghost's like, repost, reply and note reach PrivaPub; alice's post reaches its Network feed; both unfollows; statistics.

Owncast 0.3.0

  • The instance is one Service actor (/federation/user/<name>): it is followed, never follows, posts when it goes live and what its admin sends, and lists the likes, boosts and follows it receives as "fediverse engagement". Federation is off until the admin turns it on with the server's address.
  • Pasture evidence (2026-10-05, tools/pasture/scenarios/owncast.sh): 13 checks pass, with no change to PrivaPub: alice follows the stream and is listed, the admin's message reaches her home, her like and boost show in its admin, the unfollow, statistics.

WriteFreely 0.17.2

  • Each blog is an actor (/api/collections/<alias>) whose posts go out as Articles with their title and the whole text; a blog follows nobody and takes no replies or likes, so nothing goes the other way.
  • It makes each blog's keys with the openssl command when the blog first federates: without it every delivery to the blog answers 500 (its release tarball needs the command beside it).
  • Pasture evidence (2026-10-05, tools/pasture/scenarios/writefreely.sh): 13 checks pass, with no change to PrivaPub: alice follows a blog, its post reaches her home as an Article with its title, its edit and deletion follow, the unfollow, statistics.

snac2 2.95

  • Keeps everything in files and works through what it receives one message at a time, sometimes minutes behind; its Mastodon API's timelines lag behind what it has taken in, and give an object a new id when it changes. The scenario reads snac's own files instead (object/<md5>.json, the user's private.idx).
  • Its API takes a poll only as form fields (poll[options][]), has no conversations, and gets its token from its own login form (/oauth/x-snac-login) and a code grant. A Delete takes the post out of the timeline and keeps the object.
  • Its single-threaded server answered one delivery with a 502 through Caddy; PrivaPub's retry 22 seconds later went in.
  • Pasture evidence (2026-10-05, tools/pasture/scenarios/snac.sh): 27 checks pass, with no change to PrivaPub: follows both ways, posts, replies, likes and boosts both ways, a poll and alice's vote, edits and deletions both ways, direct messages both ways, the unfollow, statistics.

Mitra 5.9.1

  • Signs its deliveries with RSA (draft-cavage) and adds an FEP-8b32 proof made with its Ed25519 key (FEP-521a), which PrivaPub verifies when the activity comes forwarded; the HTTP signature is enough for what it delivers itself.
  • Prefers a proof to the HTTP signature: an activity delivered with a proof by a key Mitra has not read is refused (401, "key not found in cache"), and Mitra does not read the actor again. So nothing PrivaPub delivers directly carries a proof; only what goes to relays does.
  • Its Mastodon API resolves an account elsewhere only when the search is not limited to a type (/api/v2/search?resolve=true, no type=accounts); reactions go through Pleroma's route (PUT /api/v1/pleroma/statuses/:id/reactions/:emoji).
  • Pasture evidence (2026-10-05, tools/pasture/scenarios/mitra.sh): 28 checks pass, with no change to PrivaPub: follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote, edits and deletions both ways, direct messages both ways, the unfollow, statistics.

Relays: Activity-Relay 2.0.9 and aode-relay 0.3.129

  • Activity-Relay takes a subscription as a Follow of Public from an actor with a shared inbox (Mastodon's way) and passes every public activity a subscriber sends on to the others as it came, signed with the relay's key; only a follower whose actor ends in /relay (LitePub's way) gets an Announce instead.
  • aode-relay takes either kind and announces the post from its own actor.
  • A forwarded post carries its author's LD signature when Mastodon wrote it; PrivaPub does not verify LD signatures, so it reads such a relayed post again from its origin (one signed request each). One carrying a FEP-8b32 proof by its author's Ed25519 key (Mitra's, Fedify's, PrivaPub's) is taken as it came.
  • Mastodon 4.7 takes what Activity-Relay forwards only with an LD signature or a FEP-8b32 proof; personas' activities going to a relay carry a proof, so their public posts reach Mastodon through it too. Mastodon reads a known account's keys again at most daily, so a persona's key reaches a server that held its actor before at its next refresh.
  • Pasture evidence (2026-10-06, tools/pasture/scenarios/relay.sh): 16 checks pass: PrivaPub's instance actor subscribes to each relay and takes its Accept; Mastodon subscribes; a public post of a Mastodon account nobody here follows reaches the federated timeline, forwarded by Activity-Relay and announced by aode-relay (as its author's, never as the relay's boost), and nobody's home; a persona's public post goes to the relays, nothing less public, and reaches Mastodon through both (forwarded on its proof, and announced).

Hubzilla 11.4.1 (pubcrawl)

  • Channels speak ActivityPub only with the "Activitypub Protocol" app installed for them (the pubcrawl addon on the hub first). Accounts on Hubzilla 11 come only from its registration queue; the pasture writes the account row as that would (salt, whirlpool(salt . password)).
  • A channel's role decides what its connections may do. Hubzilla 11 grants what a role's perms_connect lists only for the roles public, personal, group and custom; the older names its role list still shows (social, social_restricted, ...) grant a new connection nothing, so it drops everything that connection sends ("store: no permission") while answering 200.
  • Threads are its owner's: a comment by another channel in a thread goes to the owner, who passes it on to the thread's audience as the commenter's own Create, with the commenter's FEP-8b32 proof, and adds each activity to the thread's context (FEP-171b, Add{Create}). PrivaPub takes the forwarded Create on its proof; the Add is dropped, the Create having said it already.
  • Signs everything with a proof (FEP-8b32) and verifies HTTP signatures; NodeInfo comes from its statistics addon.
  • An unfollow changes nothing there (G-0010): Activity::unfollow deletes the setting system.their_perms, which Hubzilla 11 keeps one permission per row under their_perms, so the follower stays.
  • Pasture evidence (2026-10-06, tools/pasture/scenarios/hubzilla.sh): 20 checks pass and one gap is expected (G-0010): follows both ways, posts both ways, comments both ways, a third channel's comment passed on by the thread's owner, likes both ways, edits and deletions both ways, statistics.

Smithereen 1.0.3

  • Walls: a post is a Note; one written on someone else's wall carries the wall (sm:wall, FEP-400e) as its target (an object with the owner as attributedTo), and the wall's owner tells its followers with Add{Note}. Smithereen sends both only to servers whose actors have a wall (Server.Feature.WALL_POSTS, set for a whole domain the first time it reads an actor there with sm:wall, never unset). It reads properties through JSON-LD compaction, so wall, privacySettings and its keys need their sm: terms in the context, and an allowedTo must stay a plain string. Its posting rule takes one base rule (followers, or following, or friends), so a persona says followers. An owner deleting someone else's post on its wall sends nothing; a Remove from the owner (its target a plain id) deletes the post there. Comments are replies addressed to the post's author, never to followers.
  • Friends are mutual follows. A request is an Offer{Follow} sent only to actors with sm:supportsFriendRequests; anyone else gets a plain Follow, so a persona is followed, and following back makes them friends there.
  • A repost is a quote: a QuoteRequest to the quoted author, then a Create{Note} quoting the post. Its API answers 500 when wall.repost has no message, even an empty one.
  • Private messages are Notes to their recipients, in threads.
  • Polls: a vote is a Note with name and inReplyTo, as Mastodon's.
  • Running it: its NodeInfo throws until the server has a short description (Objects.requireNonNull where requireNonNullElse was meant); its HTTP client refuses private addresses; its schema updater makes stored functions, which MySQL takes only with log_bin_trust_function_creators. A database whose first update failed is left without its triggers, and the server then answers 404 to activities about anything it knows.
  • Pasture evidence (2026-10-06, Smithereen 1.0.3, tools/pasture/scenarios/smithereen.sh): 40 checks pass: smuser follows alice and she follows back, which makes them friends there; Smithereen learns that PrivaPub's actors have walls; wall posts both ways; smfriend's post on smuser's wall reaches alice as on that wall (G-0009 closed); smuser writes on alice's wall, which she sees and her wall lists, her followers' servers are told, and taking it off deletes it on Smithereen; comments both ways; likes both ways; smuser's repost is a quote alice's policy lets in, and alice's boost is a repost there; smuser's poll and alice's vote; edits and deletions both ways; private messages both ways; the unfollow; statistics. PrivaPub changed for it: a server description that failed is asked again on the server's next arrival instead of a week later; walls (owner decision 2026-10-06).

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 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 done (2026-10-05): the post is locked, as a community's lock locks it, and our replies are refused.
    • P3: ignore Add{Story} without an error; answer FeatureRequest with Reject.

Pasture evidence (2026-10-05, Pixelfed 0.14.4, tools/pasture/scenarios/pixelfed.sh): 26 checks pass. Follows both ways; photos both ways with their alt text, Pixelfed's with its place (Rome, from its own list of cities) shown with its coordinates and its picture served through our proxy; comments both ways; likes both ways, a boost and an unlike; an edit and deletes both ways; statistics. The town's pair (specs/pixelfed-pair.json) passes 145 checks with 2 expected failures (G-0007). What it showed:

  • Pixelfed names our post by its page (/@name/<id>, the post's url) in its Like, Announce and Undo, so its likes were dropped as unknown objects until PrivaPub took a page address of its own posts for their id (26dac40).
  • A remote post is kept with its url as uri and its id as object_url; anything read from its database must match either.
  • On PostgreSQL every remote boost failed (and every DM, by Pixelfed's own note): the migration meant to make caption and rendered nullable there checks for a connection named postgres, while Laravel's is pgsql, so it never runs. The pasture applies it; a Pixelfed on PostgreSQL in the wild still has the bug.
  • A delivered DM is handled as one, a fetched one is not (G-0007): resolving a DM's address makes Pixelfed's instance actor fetch it, which we allow since a recipient lives there, and getScope files it as followers-only, readable by the sender's followers on Pixelfed.
  • A top-level post without a picture is dropped as it arrives, and a reply is kept only under a post it holds. A Note from an account nobody there follows is dropped too (AP_INGEST_STORE_NOTES_WITHOUT_FOLLOWERS).
  • Passport refuses a token whose user id equals its client's id (it takes it for a client-credentials token), so the pasture numbers its OAuth clients from a million.

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. Seen in the pasture (2026-10-05): until PrivaPub verified RFC 9421, its first delivery got 401 and was sent again with draft-cavage, which it then kept using.
    • It drops followers-only replies.
    • Pasture evidence (2026-10-05, WordPress 6 with ActivityPub 9.3.1, tools/pasture/scenarios/wordpress.sh): 17 checks pass: alice follows an author and is kept as its follower; a published post arrives as an Article with its title; her reply becomes a comment on the post, her like and boost comments of their kinds (like, repost); the author's edit and the post's removal reach PrivaPub; statistics. It federates from WP-Cron, and sends its requests through its own CA bundle (wp-includes/certificates).
  • 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; Mobilizon answers Accept or Reject naming it; Leave. A persona joins and leaves since 2026-10-05 (owner decision): its Join goes to the organiser alone, and Mobilizon takes a participant of an open event at once (role participant) and answers Accept. It never fetches the Join, so PrivaPub does not serve it. joinMode invite and external are refused before anything is sent.
    • The organiser (actor on the event) sends its Create, Update and Delete; the event is attributed to the group, which announces the Event itself, not the activity. PrivaPub refused the organiser's activities as misattributed until 2026-10-05, so an edit was lost and a deletion left the event in place; it now takes them as Mobilizon has the event (FEDERATION.md, "Another account of the same server").
    • An event made through its API without options has its comments closed (commentsEnabled: false); PrivaPub refuses replies to it, as it does to a locked thread.
    • Its NodeInfo names the software "Mobilizon" (NodeInfo wants lower case) and sends Content-Type: application/json; profile=http://…# with the URL unquoted, which .NET cannot parse: PrivaPub reads the raw media type, and lowercases software names.
    • Pasture evidence (2026-10-05, Mobilizon 5.2.4, tools/pasture/scenarios/mobilizon.sh): 27 checks pass: alice follows a group; the event its organiser makes arrives as an Event with its start, end and located place; she joins it (a participant row on Mobilizon, its Accept back as privapub.event.participation: accepted) and leaves it (the row gone); comments both ways; the organiser's edit, closing the comments (PrivaPub then refuses a reply) and deletes of a comment and the event; a group post with its title; the unfollow; statistics.
  • Gancio: a single Application actor; location is an array of VirtualLocation and Place; no RSVP.
    • Pasture evidence (2026-10-05, Gancio 1.28.2, tools/pasture/scenarios/gancio.sh): 17 checks pass: alice follows its actor relay; a published event arrives as an Event with its start, end and place; her reply is kept as one of the event's resources (once enable_resources is on); its edit and deletion reach PrivaPub; the unfollow; statistics.
  • 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}.
    • Its Accept names our Follow under an id of its own on our origin (…/alice#follows/<uuid>), and its own id is that plus /accept: PrivaPub refused it as off its actor's origin (400) until 2026-10-05, so no follow of a channel ever completed.
    • A channel deletes uploads in one Delete with no id, the uploads' ids in a list as the object's id: PrivaPub read no id there until 2026-10-05.
    • Its NodeInfo discovery names the document under its swagger schema's URL as rel; PrivaPub now takes a link whose path names NodeInfo. A channel's followers and following collections answer 500.
    • Pasture evidence (2026-10-05, Funkwhale 2.0.11, tools/pasture/scenarios/funkwhale.sh): 16 checks pass: alice follows a channel; a track uploaded to it arrives as Audio with its file and duration; its deletion; the unfollow; statistics.
  • 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 (Funkwhale's Audio arrives with its file and duration, 2026-10-05). 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 (done 2026-10-05) 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; Mitra prefers a proof to the HTTP signature and refuses one by a key it has not read Verify, so relayed or forwarded objects need no refetch. Done 2026-10-06, both ways: personas' activities to relays carry one, forwarded ones with a valid proof are taken as they came 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. Done 2026-10-06, both ways, checked live against Mastodon. 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)