Castopod 1.15.5 (peers/castopod.sh): the official image on the shared MySQL, its podcast @pod made and published through its admin pages, episodes through its REST API, its fediverse queue sent by spark. scenarios/castopod.sh, 15 checks: the follow, an episode reaching alice with its sound, her like, boost and comment landing there, the unfollow, statistics (from NodeInfo2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
99 KiB
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 asacct:. Akkoma and Iceshrimp.NET require both. A bareuser@domainor@user@domainis taken as anacct:too, as Mastodon, GoToSocial and Pleroma take it (2026-10-03).
- Served documents are
- SSRF guard:
- Its IPv6 rule is an allowlist (
2000::/3only), 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.
- Its IPv6 rule is an allowlist (
- 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. Actorpublishedis truncated to the day. Thewebfingerproperty (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)
-
summaryis read as a content warning on every object type. It is one only on aNote, or whensensitive: trueis set. Elsewhere it is something else:Sender What summaryholdsWordPress, 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 inActivityPubRenderer.Context()in the same commit.
Smaller
- No
Vary: Accepton actor and object URLs, although they answer HTML or JSON depending onAccept. - Only
create-…andannounce-…activity ids dereference.follow-,like-,accept-andundo-…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:
NoteandQuestiononly. - Federated
Block. Add/Removefor pins, featured hashtags and featured collections (4.6, FEP-7aa9).Move.QuoteRequest, itsAccept/Reject(withresult), andDelete{QuoteAuthorization}.FeatureRequest/FeatureAuthorization(4.6).
- Objects:
- Threads:
contextis a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+). - Interaction counts:
likesandsharescarrytotalItems. - Quotes:
quote, plusquoteUriand_misskey_quote,quoteAuthorization, andinteractionPolicy.canQuote. - Link previews: from 4.7, a
{type: Link, href}attachment names the link a card is made from (FEP-8967). - Attachments:
duration, andiconthumbnails. - 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:
idequals the URL requested, exactly.- The type is
activity+json, orld+jsonwith the profile. @contextincludes the ActivityStreams URL.
- WebFinger: it loops back to the same
id. - draft-cavage signatures:
(request-target)includes the query string (4.3).digestis signed on POST andhoston 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-uriandcontent-digest, and includecreatedandkeyid. - 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
canQuotecannot 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
repliesas its instance actor, up to 500 per post, and deletes its copy when a refetch answers 404. - Converted types:
Article,Page,Event,Video,AudioandImagebecome<h2>name</h2>+ summary + link. The body is dropped. OnlyMentiontags notify. Previews come from fetching the page; FEP-8967'spreviewis 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.testas 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 asdirectthere, and Mastodon keeps adirectpost 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 atparams[:account_username]. A delivery to the numeric/ap/users/:account_id/inboxit 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
ccand 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.
- A post addressed only to
- 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
Blockfrom Mastodon is enforced since 2026-10-04 (BlockHandler,BlockedBy): follows end both ways, the blocker is hidden from the persona, and the relationship saysblocked_by.
GoToSocial: 0.22.1 (2026-07-20)
Emits
- Objects:
NoteandQuestiononly. - Interaction policies on every status:
canLike,canReply,canAnnounceandcanQuote, each split intoautomaticApprovalandmanualApproval.- 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.
canQuoteis author-only on every post (0.21).
- Since 0.21 it asks politely:
LikeRequest,ReplyRequestandAnnounceRequest, with the interaction ininstrument. The answer isAccept/Rejectwhoseresultis a*Authorization. The interaction then carriesreplyAuthorization(withapprovedByas 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.featuredholds URIs only, and changes to it are never announced, so read it instead.
- The key id is
- 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, orld+jsonwith the profile. Anything else gets 406. - Activities: one without an
idis 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/LikeRequestinstead of a bare Create or Like.
- Third-party GoToSocial servers drop replies that have no valid
- 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 |
|---|---|---|
interactionPolicy (with GoToSocial's defaults); disable or mark actions |
P1 | GoToSocial-style Status.interaction_policy |
ReplyRequest/LikeRequest where approval is needed; handle Accept{result}/Reject; attach the authorizationInteractionApprovals), 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_contentplussource{content, mediaType: text/x.misskeymarkdown}(MFM).- The quote is
_misskey_quote/quoteUrl, plus aRE:fallback inside the content. - An empty CW is a zero-width space.
- Attachments: each carries its own
sensitive. - Emoji tags carry
_misskey_license. - Polls:
QuestionwithoneOf/anyOf. Counts are inreplies.totalItemsper option.endTimeandclosedare set; there is novotersCount.- A vote is
Create{Note{name, inReplyTo, to:[owner]}}, one per choice. - Every vote triggers an
Update{Question}.
- A vote is
- 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.
- One reaction per user per note; a plain like is
- 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
Linktag (it deliberately leaves outquote); - a
repliescollection; 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
QuoteRequestis 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.
- draft-cavage over
- Activities:
- An activity's
idmust 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.
- An activity's
- Fetched documents: request URL = final URL =
id; ≤256 KiB;activity+jsonorld+json. - Actor collections must be on the actor's host.
- Visibility: Misskey recognises followers-only by the author's own
followersURL, 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 shouldAnnouncethe 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,@reverseand@includedare refused;- every actor must resolve through WebFinger;
preferredUsernamemust 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
sourcekept; - replies threading both ways;
- 👍 and 🎉 reactions both ways, and their withdrawal;
- a like counting as a reaction;
- legacy quotes both ways (
_misskey_quotein,renoteIdout); - polls and votes both ways;
specifiednotes 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
Linktag 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:
@contextwith the instance's ownlitepub-0.1.jsonldURL (never fetch it) and@language;source{content, mediaType: text/markdown};contextandconversationholding 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 aNote{name, inReplyTo, to:[], cc:[owner]}. An open poll's end is inclosed, and Akkoma sends noendTime(pasture, 3.20.1). - Pleroma only:
ChatMessage, sent only to actors withcapabilities.acceptsChatMessages;Listen{Audio};- outgoing
Blockis on by default.
- Akkoma only:
- local-only posts are addressed to
<base>/#Public, which is not public; - FEP-2c59.
- local-only posts are addressed to
Expects
- Pleroma's inbox guard answers 400 for unknown activity types:
Move,QuoteRequestandBiteare not on its list (develop, 2026-09-30). Treat that 4xx as final. - Signatures (Akkoma):
hostmust 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
tocontains/followersor itsccis not empty; otherwise it is direct. Our followers-only posts therefore name/groupiesincctoo. - Activity ids must be at least 8 bytes.
- ObjectAgePolicy (default in both) delists anything older than 7 days, so
publishedmust be accurate. - Quotes: Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. Neither reads FEP-044f
quote, so sendquoteUriand_misskey_quoteas well. - Edits: a changed
nameis 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
publishedtime kept; - replies both ways and their notification;
- likes and boosts both ways with their undos;
EmojiReactboth ways and its withdrawal;- DMs both ways and off public timelines;
- polls and votes both ways;
- quotes both ways (
quote_idout of its API,quoteUriin 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
tocontains/followersor itsccis not empty. Ours is/groupiesand a post mentioning nobody had an emptycc. Followers-only posts now name the followers collection inccas 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 noendTime. Aclosedin the future is now read as the end.
Seen along the way:
- Its Linkify never takes
@user@host.testfor a mention, whatevervalidate_tldsays, so in the pasture Akkoma addresses us with Pleroma'sto[]. Real top-level domains are unaffected. - It records our
Block(user_relationships) but never reports a remote blocker asblocked_by. - Its streamer crashes rendering a new DM conversation (
ConversationView, a nillast_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) andsource(sidebar Markdown); a plaindescriptionin 1.0;sensitive;attributedTo→ the moderators collection;featured;postingRestrictedToMods;language[];- 1.0 adds:
manuallyApprovesFollowersfor private communities,discoverable: falsefor unlisted ones, and the community's post tags intag[]asCommunityPostTagwith colour slotscolor01–color10.
- Page (post):
name,contentandsource;- a link post is
attachment[0] = Link{href, mediaType}, withimageas the thumbnail; - 1.0 image posts are
Image{url, name}, wherenameis 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/Dislikeas{actor, object, audience}with noto/cc, and theirUndo. - Moderation:
- a
Deletewith asummaryis a mod removal (1.0 addswithReplies); itsUndorestores; Lock/Undo{Lock}(1.0 also on comments);- a ban is
Block{target, removeData, summary, endTime}(0.19 also sendsexpires); Add/Removeof featured posts and moderators;Update{Group}by the moderator Person;- 1.0 adds
WarnandResolve{Flag}.
- a
- Delivery: everything travels inside the community's
Announce. For a new post Lemmy also sends a compatibilityAnnounce(Page)with a synthetic id; deduplicate it. - Private messages:
ChatMessageon 0.19; a single-recipientNoteon 1.0. - Context: 1.0's
contextcollection is unpaged and every comment's own context URL returns the whole post thread. Group threads by the root post, never by comparingcontextstrings. - Feeds: multi-communities are 1.0's
type: Feedactors.
Expects
- Fetched documents: Content-Type exactly one of
activity+json,activity+json; charset=utf-8, orld+jsonwith the profile; andidequals the URL fetched. - Addressing:
- Create, Update, Lock, Delete and Block must carry both
toandccarrays. - 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.
- Create, Update, Lock, Delete and Block must carry both
- Accepted types: Page, Article, Note, Video and Event become posts.
Questionis 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);objecta URL or an array; the reason insummaryorcontent. - 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 asNotes; - statistics.
What it showed:
- 1.0 keeps its user's thread in a remote community
federation_pendinguntil 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, soGroupDistributorsends theAnnounce{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 compatibilityAnnounce(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}andAnnounce{Dislike}: counted sinceed08f80, and a vote turned the other way replaces the first sincebbeeda7. 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:sortvalues are lowercase (new), private messages and mentions are inaccount/notification/list, andresolve_objecttakes both!community@hostand@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-rootsadded (tools/pasture/images/lemmy19) and runs it aslemmy19.test. - 0.19 takes private messages only as
ChatMessage(ChatMessageTypehas noNote) and answers our directNote400. 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 aChatMessage, 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 aChatMessage(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 (
QuestionwithvotersCount), and Mobilizon-styleEvents; - flairs in two dialects;
- comments:
repliesEnabled(comment lock),answer, plus customChooseAnswerandPollVoteactivities; Move{object: post}between communities;Feedactors;- emoji Likes and
EmojiReactcount as upvotes.
- posts:
- 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}withoutpublished. - Batched Announces (FEP-1a11) go only to PieFed and Pylova.
- Mbin:
- Thread
summaryis "short title + tags", so it is not a CW. - On link threads
sourceis a plain URL string. commentsEnabledandstickiedare sent.- A Person's
Announcecounts as an upvote; Mbin reads thelikes/dislikes/sharescounts 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,toandaudiencenaming 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(andUndo{Lock}) from the moderator, not inside an announce; taken since 2026-10-05 from the post's own server. A removal ("trash") is aDeletefrom the moderator, believed once Mbin answers the thread gone. - Votes: an upvote is an
Announce, a favourite aLike. Downvotes stay on Mbin (noDislike), 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 directNoteis dropped ("PM: not implemented"), and Mbin's actors say nothing that would let a sender choose, so PrivaPub sends aChatMessageto 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'surl), 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.
- Thread
- Gaps:
- P1: Lemmy's P1 set, W2, and tolerating
sourceas a string. - P2: galleries; post
Move;repliesEnabled;nsfl; flairs; publishlikes/sharestotals. - P3:
genAI; accepted answers; batched Announces.
- P1: Lemmy's P1 set, W2, and tolerating
- 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>/inboxwhen the root answers anything else. PrivaPub answered 404 there until 2026-10-05, so every announce went to a/inboxit 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
Articlewithname,summary= an excerpt andpreview; replies are Notes. - Categories are
Groups withoutfollowers. contextis a paged collection with an ETag digest; NodeBB refetches withIf-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/Removeof a whole context (FEP-f15d) andAdd{post → context}(FEP-11dd). - Chats are private Notes, threaded by
inReplyTointo the room they answer. - It never federates a topic's lock (its
activitypub/out.jshas noLock), 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.
- A topic's first post is an
- 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 sendsDislike. instrument{Service}names the software.- It sends
Followwith a post as the object, meaning "include me in this thread". Answer that withRejector 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::putleaveseditedalone, and only a changededitednotifies. 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: trueat 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=trueunlocks 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.
- Group accounts relay with
- Gaps:
- P1: W2 for NodeBB Articles (
privapub.excerpt). - P2: Groups without
followers; Announces from an Application; context Move/Remove; pagedcontextwith ETag; Friendica's thread-Follow. P1: forwarded activities refused with 401done 2026-10-05 (Forwarded).
- P1: W2 for NodeBB Articles (
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.
- Live now means
- Other activities:
- Comments are Markdown Notes.
Viewcomes 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 validurlandpublished; - have an
idon the actor's host; - have an
inReplyTothat resolves to the video or one of its comments.
commentsPolicy2 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 aCreate, 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 PNGimage/jpegin 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,CommentandQuotationabout a book (inReplyToBook) for other BookWyrms, and "pure"Articles andNotes (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 asArticles 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 itsHostis 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
Serviceactor (/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 asArticles 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
opensslcommand 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'sprivate.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. ADeletetakes 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, notype=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
FollowofPublicfrom 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 anAnnounceinstead. - 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).
Castopod 1.15.5
- A podcast is the actor (
@pod@castopod.test); its people are not. It accepts follows by itself. - An episode goes out as a Note that only links to the episode's page; the page serves a
PodcastEpisode(its words indescription, its sound inaudio, its cover inimage). PrivaPub readsPodcastEpisode, and a Note that is only a link to its author's episode takes the episode's sound, title, cover and words (one signed fetch), so the post plays. - Publishes NodeInfo2 only (
/.well-known/x-nodeinfo2); PrivaPub's server description falls back to it. - Running it: the official image on the shared MySQL with a file cache;
spark install:init-database(a second run stops before seeding:db:seed AppSeeder) andinstall:create-superadmin; a podcast is made and published only through its admin pages (a 1400px square cover, a 3:1 banner and an emptylocation_nameare required, or the form fails); episodes through its REST API (restapi.*appended to the.envit writes at each start); its fediverse queue goes out throughspark fediverse:broadcast. - Pasture evidence (2026-10-06,
tools/pasture/scenarios/castopod.sh): 15 checks pass: alice follows the podcast, an episode reaches her with its sound, her like, boost and comment land there, the unfollow, statistics.
Forte 26.9.10
- Portable identities (FEP-ef61): a channel's actor is
https://<server>/.well-known/apgateway/did:key:z6Mk…/actor, its keys adid:keyand Multikeys; WebFinger listsap://did:key…and the gateway address beside the usual ones. PrivaPub keeps it as an ordinary actor at that https address. - Follows a profile page: its
Follownames the persona's profile URL (/@name, WebFinger's alias) rather than the actor's id; PrivaPub takes either (a 404 before). - Everything in a thread is added to its conversation (FEP-171b): its edits come only as
Add{Update}, under the same activity id as the post'sCreate, and likes asAdd{Like}. PrivaPub unwraps Create, Update and Delete, telling an Update from the Create whose id it reuses by what it carries. - Its own collections name themselves wrongly: a channel's followers, following and outbox give an
idwith the gateway path twice and a trailing?, which 404s, so their counts are not read. - Running it: no image is published;
images/fortebuilds its tag with composer on Hubzilla's PHP image, behind an nginx that routes throughindex.php?req=. Its curl useslibrary/cacert.pem, not the system store. Thesocialrole leaves each connection pending; the pasture's channels setautoperms. Background jobs it starts from the command line do not outlivepodman exec, so the scenario runs the Follow's notifier itself. Likes takeverb=Like. - Pasture evidence (2026-10-06,
tools/pasture/scenarios/forte.sh): 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, the unfollow, statistics.
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_connectlists only for the rolespublic,personal,groupandcustom; 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, and unwraps theAddas the same activity passed on (one job for both, whichever comes first); an addedLikeis not taken. - Signs everything with a proof (FEP-8b32) and verifies HTTP signatures; NodeInfo comes from its
statisticsaddon. - An unfollow changes nothing there (G-0010):
Activity::unfollowdeletes the settingsystem.their_perms, which Hubzilla 11 keeps one permission per row undertheir_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 itstarget(an object with the owner asattributedTo), and the wall's owner tells its followers withAdd{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 withsm:wall, never unset). It reads properties through JSON-LD compaction, sowall,privacySettingsand its keys need theirsm:terms in the context, and anallowedTomust 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; aRemovefrom 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 withsm:supportsFriendRequests; anyone else gets a plainFollow, so a persona is followed, and following back makes them friends there. - A repost is a quote: a
QuoteRequestto the quoted author, then aCreate{Note}quoting the post. Its API answers 500 whenwall.reposthas nomessage, even an empty one. - Private messages are
Notes to their recipients, in threads. - Polls: a vote is a
NotewithnameandinReplyTo, as Mastodon's. - Running it: its NodeInfo throws until the server has a short description (
Objects.requireNonNullwhererequireNonNullElsewas meant); its HTTP client refuses private addresses; its schema updater makes stored functions, which MySQL takes only withlog_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
Documentwhoseurlis a string. The poster is in the object'spreview. Videos are vertical. - Its interactionPolicy follows GoToSocial's model.
- It sends
QuoteRequestandFeatureRequest. - 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.
- A video is a Note with one mp4
- Pixelfed:
- Posts are a Note with attachments.
- It also sends
location: Place{name, latitude, longitude, country},commentsEnabled,capabilities, andcanQuote(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
urland amediaTypein the instance's list. The default list is jpeg, png and gif only, and a single webp or avif attachment rejects the whole post.
- Only
- Gaps:
- P1 outbound: keep JPEG/PNG renditions with an explicit
mediaType. - P1 inbound: Loops'
previewposter. P2: Pixelfeddone:location→ ownprivapub.place, display only and never re-federatedObjectShapes.NotePlacekeeps aPlacewith coordinates (Pixelfed sends them as strings) inPost.Place, an edit replaces it, and nothing renders it back out.P2:done (2026-10-05): the post is locked, as a community's lock locks it, and our replies are refused.commentsEnabled: falsedisables replies- P3: ignore
Add{Story}without an error; answerFeatureRequestwithReject.
- P1 outbound: keep JPEG/PNG renditions with an explicit
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'surl) 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
urlasuriand its id asobject_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
captionandrenderednullable there checks for a connection namedpostgres, while Laravel's ispgsql, 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
getScopefiles 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. idis?p=123, different fromurl.- The blog actor is a Group with
attributionDomains. - It sends an
Updateon 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
imageas a bare string andpreview; 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.
- Article with
- WriteFreely: Article when the body has a paragraph break.
previewreuses 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 undermz:keys; a bannerDocument. - The event is attributed to the Group.
- RSVP:
Join{object: event}with a stable id; Mobilizon answersAcceptorRejectnaming it;Leave. A persona joins and leaves since 2026-10-05 (owner decision): itsJoingoes to the organiser alone, and Mobilizon takes a participant of an open event at once (roleparticipant) and answersAccept. It never fetches theJoin, so PrivaPub does not serve it.joinModeinviteandexternalare refused before anything is sent. - The organiser (
actoron 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, itsAcceptback asprivapub.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.
- Times and status:
- Gancio: a single Application actor;
locationis an array ofVirtualLocationandPlace; no RSVP.- Pasture evidence (2026-10-05, Gancio 1.28.2,
tools/pasture/scenarios/gancio.sh): 17 checks pass: alice follows its actorrelay; a published event arrives as an Event with its start, end and place; her reply is kept as one of the event's resources (onceenable_resourcesis on); its edit and deletion reach PrivaPub; the unfollow; statistics.
- Pasture evidence (2026-10-05, Gancio 1.28.2,
- Friendica, Hubzilla: RSVP with
Accept/Reject/TentativeAccept. Hubzilla creates events asInvite{Event}, with HTML inlocation.content, and-00:00for floating times. - FEP-8a8e: a server that does not handle joins answers
JoinwithIgnore. - 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}whoseurlis an array: a page link plus the stream, withbitrateandsize.- Also
duration,position,disc,album,licenseandimage;summaryis 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
Deletewith no id, the uploads' ids in a list as the object'sid: 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
PodcastEpisodewith transcript and chapters. - Owncast: a
Serviceactor; "go live" is a Note with a thumbnail. - Gaps:
P1: pick the audio Link out of(Funkwhale's Audio arrives with its file and duration, 2026-10-05). Surfaces asurl, sniff its type, apply W2MediaAttachment{type: audio}withmeta.original.durationand the cover aspreview_url.- P2: duration, cover, album, position and licence (own
privapub.audio); the Castopod transcript and chapters. - P3:
Listenhistory; Owncast live state.
Books: BookWyrm 0.9.3, NeoDB 0.19.4
- BookWyrm:
- To non-BookWyrm servers it sends a review as an
ArticlenamedReview of "Title" (★★★★): …, a comment or quotation as a Note with the citation appended, and the cover as aDocument. - The fields
inReplyToBook,ratingandquotego only to BookWyrm servers.
- To non-BookWyrm servers it sends a review as an
- 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,tagentries that are not Mention or Hashtag, and covers that have no blurhash. - P2: keep
relatedWith,ratingandinReplyToBookraw; a card for the item; ownprivapub.review{item, rating, scale}.
- P1: keep the Article
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 + anRE: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
Deletepromptly; 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
Announcebare 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.
- Each flip is a link-only Note: headline, a link with
- 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).alsoKnownAsholdsdid:values; there is nopublishedand the outbox is empty. - Posts: a
urlarray with anat://canonical link (W1); media withoutmediaType(W4); link embeds flattened intocontent; video thumbnails inimage; long-form as Article. - Bridging a persona:
- opt-in, by following the bot;
- the persona needs an
iconand must be at least 7 days old; - only public posts are bridged;
- opting out takes a
Blocksent 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.
- Actors are
- P2:
Account.created_atfallback; 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:
- pages are fetched by the server for public posts;
- blocks federate;
- website authorship stays off;
- PeerTube views are never sent;
- Bluesky bridging is per persona, with persona dates randomised;
- a one-time notice before the first reaction or vote.
The options as they were laid out:
- Link previews.
- Building a card from the object, or from FEP-8967
previewdata, 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.
- Building a card from the object, or from FEP-8967
- 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.
attributionDomains/fediverse:creator. It ties a persona to a website. Opt-in per persona only.- PeerTube
View. Counting a view tells the origin a video was watched. If ever, send it from the instance actor. - Bridging to Bluesky. It is per persona; each persona needs its own avatar and 7 days of age. Same-day personas
share a
publishedday. - 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) |