- **`summary` is read as a content warning on every object type.** It is one only on a `Note`, or when
`sensitive: true` is set. Elsewhere it is something else:
| Sender | What `summary` holds |
|---|---|
| WordPress, WriteFreely, Ghost, NodeBB (`Article`) | a teaser or excerpt |
| Mobilizon, Gancio (`Event`) | the date and address, or the whole description |
| Funkwhale (`Audio`) | a line of hashtags |
| Mbin (`Page`) | a short title plus tags |
Today all of these arrive hidden behind a warning, and marked sensitive.
- **Persona and group usernames are only lowercased.** Mastodon accepts `[a-z0-9_]` with `.` and `-` only inside the
name; Misskey `^\w([\w-.]*\w)?$`, 128 characters at most. A persona named outside that is unreachable from either.
- **Our JSON-LD context does not define `postingRestrictedToMods`.** Iceshrimp.NET runs full JSON-LD expansion and
silently drops every undefined term. Any term we add later (`quote`, `Emoji`, `EmojiReact`, `interactionPolicy`,
`votersCount`) must be defined in `ActivityPubRenderer.Context()` in the same commit.
**Smaller**
- No `Vary: Accept` on actor and object URLs, although they answer HTML or JSON depending on `Accept`.
- Only `create-…` and `announce-…` activity ids dereference. `follow-`, `like-`, `accept-` and `undo-…` answer 404.
That is harmless while objects are embedded, but every id should resolve.
## 2. Rules that hold across platforms
| # | Rule | What PrivaPub must do | P |
|---|---|---|---|
| W1 | `url`, `icon`, `image`, `attributedTo`, `actor`, `tag`, `attachment` and `alsoKnownAs` can each be a single value, an object or an array. The `url` array matters most: PeerTube video files, Funkwhale streams, and Bridgy posts, whose `rel: canonical` link is `at://…`. | Parse every shape. For "open original", use the `text/html` Link. Keep every other Link as a media variant. | P1 |
| W2 | `summary` is a content warning only on a `Note`, or when `sensitive: true`. | See §1. On other types store it as an excerpt or description. NodeBB 4.16 honours a CW only on a sensitive Note. | P1 |
| W3 | `content` can be Markdown. PeerTube descriptions and comments carry `mediaType: text/markdown`. | Render it, then sanitise. Keep `source{content, mediaType}`: Markdown, BBCode, `text/x.misskeymarkdown` (MFM). | P1 |
| W4 | `mediaType` is missing or wrong: Bridgy media has none, Funkwhale hard-codes `audio/mpeg`, Gancio `image/jpeg`. Mastodon types every attachment `Document`. | Infer it from the object or attachment type, then sniff it in the media proxy. | P1 |
| W5 | Thumbnails live in five places: attachment `icon` (Mastodon), object `icon[]` (PeerTube), object `preview` (Loops), `image` (Bridgy video; Ghost as a bare string; WordPress), and `icon` (Plume). | Keep them all; choose one per kind. | P1 |
| W6 | `Accept`, `Reject` and `TentativeAccept` are not always follow answers. Friendica and Hubzilla use them as event RSVPs; Mobilizon answers a `Join`; GoToSocial answers interaction requests with `result`; Mastodon answers a `QuoteRequest`. | Route on what the object *is*. | P1 |
| W7 | `id` is not the page a person opens. WordPress uses `?p=123`, Ghost `/.ghost/…`, Bridgy `/convert/ap/at://…`. | Link to `url`, never to `id`. | P1 |
| W8 | Rich types are updated in place: PeerTube live state, WordPress (on every save), Mobilizon. A poll's counts are refreshed with an `Update{Question}` that changes nothing else. | Apply Updates to every kind. An Update with no newer `updated` is a refresh, never an edit revision. Mastodon applies the same rule. | P1 |
| W9 | A `Delete` can arrive before its `Create`, and relays and forwarders re-send old Creates. | Keep tombstones so deleted posts stay deleted. When the deleter is not the author, confirm with the origin: 404 or 410 means deleted. | P1 |
| W10 | Time comes in seconds (Mastodon), milliseconds (Misskey, us), or with offsets. `-00:00` means floating local time (Hubzilla events). `duration` is ISO 8601 (`PT6299S`). GoToSocial rejects a status whose `updated` is earlier than `published`. | Parse all of these, and clamp future times. Order by arrival (PrivacyIds.Arrived). Never emit `updated` < `published`. | P1 |
| W11 | Language may be in `contentMap`, in `@context[].@language` (Pleroma 2.9+), or a `language{identifier,name}` object (Lemmy, PeerTube). Akkoma replaces `content` with the *first*`contentMap` entry. | Read all three. When sending, put `content` and the primary `contentMap` entry first and keep them equal. | P2 |
| W12 | Alt text: `name` (Mastodon), `summary` (GoToSocial 0.20.0, Akkoma reads it first), or their `*Map` forms. Avatar and header alt is in `icon.name`/`image.name`, or `summary` on Mastodon. | Read both; send `name`. | P2 |
| W13 | `"id": null` objects (Akkoma); a `Tombstone` served with **200** as a soft delete (FEP-4f05: NodeBB, Discourse); 410 for deleted GoToSocial 0.22 statuses. | Accept a null id inside an activity; never emit one. Any `Tombstone`, whatever the status code, means deleted. | P2 |
| W14 | Size limits on the receiving side: Misskey reads at most 256 KiB, truncates text at 8192 characters, CW 512, poll choice 256, alt 512. GoToSocial takes emoji up to 100 KB. Peers cap fetches at about 1 MB. | Accept long posts from others. Keep our own documents small. | P2 |
| W15 | Hashtag `name` comes with or without `#`. Mastodon normalises with NFKC + lowercase (watch Turkish `İ`). Lemmy adds an automatic `#<community>` tag to every post. | Normalise the same way; ignore Lemmy's automatic tag. | P3 |
| Being quotable: emit `canQuote`; answer `QuoteRequest` with `Accept{object: request id, result: stamp}`; serve and revoke stamps | P2 | `Status.quote_approval`, `PUT /statuses/:id/interaction_policy` |
| Polls (see §3 Misskey for vote shapes) | P1 | `Status.poll`, `/polls/:id`, `/polls/:id/votes`, `poll` notification |
| Custom emoji on posts, names, fields and poll options, proxied, refreshed by `updated` | P1 | `Status.emojis`, `Account.emojis` |
| Link attachments as the card source | P1 | `Status.card` |
| Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account, 7-day lock); move each persona's follow | P1 | `Account.moved` |
| Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` |
-`featured` holds URIs only, and changes to it are never announced, so read it instead.
- **What it leaves out:**
-`context`;
-`likes`/`shares`;
- attachment `width`/`height`.
- **Deleted statuses:** 0.22 keeps a stub and answers 410.
**Expects**
- **Signed requests:** every GET and POST is signed, draft-cavage only, with RSA keys. **No RFC 9421 in either
direction.**
- **Key handshake:** the instance actor and key documents must be served **unsigned**, or both sides deadlock fetching
each other's keys. Ours are: SecureMode exempts the instance actor.
- **Content-Type:** an inbox POST must be `activity+json`, or `ld+json` with the profile. Anything else gets 406.
- **Activities:** one without an `id` is dropped. A 400 is never retried.
- **Keys:** a changed public key on refresh is refused. **Never rotate keys silently.**
- **Interaction policies:**
- Third-party GoToSocial servers drop replies that have no valid `replyAuthorization`.
- On followers-only GoToSocial posts, send `ReplyRequest` / `LikeRequest` instead of a bare Create or Like.
- **Rate limit:** 300 requests per 5 minutes per IP, answered with 503 and `Retry-After`.
- **Not accepted:** top-level `Audio`.
- **Timelines:** its cached home timeline can miss new posts. Check a delivery by URI, not through its timelines; see
CLAUDE.md, "Testing".
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Store remote `interactionPolicy` (with GoToSocial's defaults); disable or mark actions | P1 | GoToSocial-style `Status.interaction_policy` |
| Send `ReplyRequest`/`LikeRequest` where approval is needed; handle `Accept{result}`/`Reject`; attach the authorization | P1 | own: pending/approved/rejected on our own reply |
| Honour 503 with `Retry-After` in delivery and in the proxy | P1 | — |
| Respect `hides*FromUnauthedWeb` on our public pages; emit it for personas (it suits the privacy design) | P2 | — |
| Measure media size when proxying | P2 | `MediaAttachment.meta` |
| Only advertise the policies we enforce | P2 | — |
- It sends contentless Likes only to Mastodon-like peers, so **we always receive Like+content**.
- **Iceshrimp.NET adds:**
-`EmojiReact`, several per user, and `:name@host:` for remote emoji;
- a FEP-7888 `context`;
-`htmlMfm: true` (FEP-c16b);
- every `QuoteRequest` is auto-accepted;
-`Bite`, `pronouns`.
- **CherryPick adds:** events on a plain Note (`startTime`/`endTime`), `deleteAt`, and federated chat
(`_misskey_talk: true`).
**Expects**
- **Inbound signatures:**
- draft-cavage over `(request-target) host date digest`;
- at most 300 s of skew;
- since 2026.10.0, the query string is included in `(request-target)`.
- **No RFC 9421 anywhere in the family.**
- **Activities:**
- An activity's `id` must be on the signer's host.
- Activities forwarded on behalf of someone else are refused. A group must `Announce`.
- Misskey answers 202 even when it drops something, so errors stay invisible.
- **Fetched documents:** request URL = final URL = `id`; ≤256 KiB; `activity+json` or `ld+json`.
- **Actor collections** must be on the actor's host.
- **Visibility:** Misskey recognises followers-only by the author's own `followers` URL, matched exactly. Otherwise:
- **A "specified" (direct) note with no resolvable recipients that Misskey fetches by URL is stored as public.**
Circle objects must therefore never be served to an unauthorised fetcher. They aren't: 404.
- **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both.
- **Reactions:** must be `:name:` with no host, plus an Emoji tag, or they fall back to ❤.
- **Article/Page titles** are never shown in Misskey's web UI.
- **Iceshrimp.NET:**
- full JSON-LD expansion (see §1);
-`@graph`, `@reverse` and `@included` are refused;
- every actor must resolve through WebFinger;
-`preferredUsername` must be unique per domain. PrivaPub shares one name space across personas and groups, so this
holds.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Emoji reactions in all three inbound forms: Like with `content`/`_misskey_reaction`, `EmojiReact`, and `Dislike`-as-un-like (Sharkey). Normalise `:n:`, `:n@host:` and `n@host`. Store per actor, emoji and activity, including reactions to remote posts. Undo by id. | P1 | `emoji_reactions` and `pleroma.emoji_reactions``[{name,count,me,url,static_url}]` (Phanpy reads the first); `PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji` |
| Outbound reaction as `EmojiReact{content: ":name:", tag:[Emoji]}`. A plain Like stays a favourite. | P2 | same |
| Polls: per-option counts (fall back to `_misskey_votes`); `votersCount` null when absent; `Update{Question}` is a refresh; an inbound `Note{name, inReplyTo: question}` with no content is a vote, never a reply | P1 | `Status.poll` |
| Keep MFM source; let the sanitiser keep `<span class="mfm-*" data-mfm-*>` and `<ruby>` (FEP-c16b) | P2 | `text`, `content_type`; the rich client renders MFM from the source |
| Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` |
| Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — |
| `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 |
### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May
join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled`
on a Page: pins live in `featured`, locks in `Lock`.
**Emits**
- **Group:**
-`summary` (sidebar HTML) and `source` (sidebar Markdown); a plain `description` in 1.0;
-`sensitive`; `attributedTo` → the moderators collection; `featured`; `postingRestrictedToMods`; `language[]`;
- 1.0 adds: `manuallyApprovesFollowers` for private communities, `discoverable: false` for unlisted ones, and the
community's post tags in `tag[]` as `CommunityPostTag` with colour slots `color01`–`color10`.
- **Page (post):**
-`name`, `content` and `source`;
- a link post is `attachment[0] = Link{href, mediaType}`, with `image` as the thumbnail;
- 1.0 image posts are `Image{url, name}`, where `name` is the alt text;
- **Flags:** exactly one `to` (community or site); `object` a URL or an array; the reason in `summary` or `content`.
- **Moderation trust:** an action is trusted when it is on the community's or the object's host (FEP-fe34), or when its
actor is in the moderators list Lemmy fetched.
- **1.0 hides a local user's post or comment in a remote community until that community Announces it back.** A
community we host must therefore announce to the author's own instance too.
- **Refused:** Lemmy does not accept an incoming `Announce(Page)`.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| `Dislike` and its `Undo`: a vote ledger per object (actor, ±1, activity, relaying group, time) | P1 | `favourites_count` = upvotes; own `privapub.vote{score, up, down, mine}`, `POST …/vote` |
| Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. | P1 | — |
| Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` |
| Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` |
| `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` |
| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — |
| Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — |
| 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 | — |
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
- Categories are `Group`s **without `followers`**.
-`context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`.
- Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors.
- It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd).
- **Discourse** (plugin, semi-dormant): categories and tags are Groups. "Full Topic" mode makes the topic an
OrderedCollection used as `context`.
- **Friendica** (2026.05-1):
- Group accounts relay with `Announce(object)`.
- Titled posts are `Page`/`Article`; it sends `Dislike`.
-`instrument{Service}` names the software.
- It sends **`Follow` with a post as the object**, meaning "include me in this thread". Answer that with `Reject` or
ignore it, without an error.
- **Gaps:**
- **P1:** W2 for NodeBB Articles (`privapub.excerpt`).
- **P2:** Groups without `followers`; Announces from an Application; context Move/Remove; paged `context` with ETag;
Friendica's thread-Follow.
### PeerTube 8.3.1 (2026-09-28)
**Emits**
The account sends `Create{Video}`; the channel (a Group) sends `Announce{Video}`, so deduplicate.
| Part of the Video | What it holds |
|---|---|
| Attribution | `attributedTo: [Person, Group]` (both, possibly bare URLs); `audience` = the channel |
| Description | Markdown in `content` with `mediaType: text/markdown`; `summary` is the CW (since 7.2) |
| `url[]` | A `text/html` watch page; per-resolution mp4 Links (`height`, `width`, `fps`, `size`, ffprobe codec types); HLS `application/x-mpegURL` (since 6.3 audio and video can be separate, with "0" as the audio-only resolution); torrent and magnet; a metadata JSON |
| Images | `icon[]`: thumbnails up to 1920 px; `preview`: storyboards |
| Captions | `subtitleLanguage[]` with VTT and HLS URLs |
-`View` comes from the server's Application actor.
-`Dislike`; `ApproveReply` (FEP-5624, since 6.2); `CacheFile` (mirrors); playlists.
**Expects**
- **Replies** must:
- be Public;
- have non-empty `content`, a valid `url` and `published`;
- have an `id` on the actor's host;
- have an `inReplyTo` that resolves to the video or one of its comments.
-`commentsPolicy` 2 rejects replies; 3 holds them until approved.
- PeerTube signs its fetches.
- It drops followers that have been unreachable for about 7 days (8.2).
**What Mastodon does with it:**`<h2>name</h2>` + summary + link. The description is dropped and there is no
attachment. The player is a card whose iframe loads from the remote host, which our proxy rule forbids.
**Gaps**
| Gap | P | Client surface |
|---|---|---|
| Store the whole Video: variants, thumbnails, storyboards, captions, chapters, duration, live state, views, `commentsPolicy`, licence, category, language, support, channel | P1 | own `privapub.video` |
| Play through the proxy: a `MediaAttachment{type: video}` pointing at a proxied muxed mp4 (a `web-video` file, or a fragmented file whose codec types include both audio and video); `preview_url` = a ~560 px `icon`; `meta.original` with width, height, `frame_rate`, `duration` | P1 | `media_attachments` |
| The proxy answers **Range** requests and rewrites HLS playlists and caption URLs to proxied ones. A 720p file is about 0.9 GB, so stream it; never buffer. | P1 | — |
| A video card made from the object, **without** a remote iframe | P1 | `Status.card{type: video}` |
| Reply rules: closed when `commentsPolicy` is 2; replies sent Public with a `url`; `ApproveReply` shows our reply as pending | P1 / P2 | own |
| Dislike counts; live state through `Update`; chapters and captions | P2 | own |
| Optionally, `View` sent from the instance actor (so it never names a persona) | P3 | — |
### Loops (1.0.0-beta.14) and Pixelfed (0.14.4)
- **Loops:**
- A video is a Note with one mp4 `Document` whose `url` is a string. **The poster is in the object's `preview`.**
Videos are vertical.
- Its interactionPolicy follows GoToSocial's model.
- It sends `QuoteRequest` and `FeatureRequest`.
- A top-level post it accepts must be a Note with an mp4 attachment, from an instance its admin allowlisted, **≤ 100 MB,
checked with a HEAD request**. Our media must answer HEAD.
- **Pixelfed:**
- Posts are a Note with attachments.
- It also sends `location: Place{name, latitude, longitude, country}`, `commentsEnabled`, `capabilities`, and
`canQuote` (0.14).
- Stories are `Add{Story}` with a bearcap only Pixelfed understands.
- Pixelfed 0.14 does FEP-044f and FEP-8fcf.
- **What Pixelfed accepts:**
- Only `Note`s, and **a top-level post must have media**.
- **Every** attachment must be a Document or Image with a string `url` and a `mediaType` in the instance's list. The
default list is **jpeg, png and gif only**, and a single webp or avif attachment rejects the whole post.
- **Gaps:**
- **P1 outbound:** keep JPEG/PNG renditions with an explicit `mediaType`.
- **P1 inbound:** Loops' `preview` poster.
- **P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated; `commentsEnabled: false`
disables replies.
- **P3:** ignore `Add{Story}` without an error; answer `FeatureRequest` with `Reject`.
FEP-b2b8 (draft) describes the shape: plain-text `name`, a `summary` teaser (≤500), full HTML `content`, `image`, and
a `preview` Note fallback.
- **WordPress:**
- Object type: an Article for a titled post, a Page for a page, otherwise a Note.
- Fields: `image` (the featured image), `preview`, `interactionPolicy.canQuote`.
- A CW is `sensitive` + `summary` + `dcterms:subject`.
-`id` is `?p=123`, different from `url`.
- The blog actor is a Group with `attributionDomains`.
- It sends an `Update` on every save, and **signs with RFC 9421 first** (9.3.0), falling back to draft-cavage after
any 4xx.
- It drops followers-only replies.
- **Ghost 6** (its ActivityPub service is separate, built on Fedify):
- Article with `image` as a bare string and `preview`; members-only parts removed.
- It refetches every object signed, **never applies remote Updates**, and accepts only Note and Article.
- Public is addressed as `as:Public`.
- **WriteFreely:** Article when the body has a paragraph break. **`preview` reuses the Article's id**, so never store
it as its own post. It has no comments.
- **Gaps:**
| Gap | P | Client surface |
|---|---|---|
| An Article shown in the Mastodon API: `content` = name + teaser (`summary`, else `preview.content`) + a link to `url`; a card made from the object (title, description, `image` then `icon`, author, provider, date) | P1 | `Status.content`, `Status.card` |
| The full sanitised HTML kept for a reader view | P1 | own `privapub.article{title, html, cover, excerpt}` |
| Body images duplicated in `attachment` removed; `attributedTo` arrays resolved to the Person | P2 | — |
- Attachments: the online link `Link{name: Website}`; `PropertyValue`s under `mz:` keys; a banner `Document`.
- The event is attributed to the Group.
- **RSVP:** `Join{object: event}` with a stable id that can be fetched; Mobilizon answers `Accept` or `Reject`;
`Leave`.
- **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP.
- **Friendica, Hubzilla:** RSVP with `Accept`/`Reject`/`TentativeAccept`. Hubzilla creates events as `Invite{Event}`,
with HTML in `location.content`, and `-00:00` for floating times.
- **FEP-8a8e:** a server that does not handle joins answers `Join` with `Ignore`.
- **Gaps:**
| Gap | P | Client surface |
|---|---|---|
| Store times, time zone and place in every one of those shapes; RSVP routing (W6); `Invite{Event}`; answer `Join` with `Ignore` until RSVP exists | P1 | `content` = title + "date (zone) · place" + link; card with the banner |
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). |
| 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. |
| Bridges and relays | bridgy-fed, activityrelay |
## 5. Signatures, identity and transport
| Topic | State on 2026-10-01 | PrivaPub | P |
|---|---|---|---|
| RFC 9421 inbound | Mastodon accepts since 4.5. WordPress and Fedify sign with it first. GoToSocial, the Misskey family, Akkoma, Pleroma and Bridgy do not. | Verify RSA and Ed25519; `content-digest` (RFC 9530); one signature; `created` and `keyid` | P2 |
| RFC 9421 outbound | Mastodon 4.7 double-knocks | draft-cavage first; RFC 9421 after a 401; remember per host | P2 |
| 400 vs 401 | A 400 or 401 makes Mastodon 4.7 and WordPress retry with the other scheme | 401 only for signature failures (we do this); 400 only for bodies that are really malformed | P1 (keep) |
| Temporary key failure | Mastodon answers 503 | We should answer 503 too, and treat a 503 as a retry in delivery | P2 |
| Keys | `publicKey` can be an array (Mastodon 4.6). FEP-521a `assertionMethod` Multikey is FINAL (Ed25519 `z6Mk…`). GoToSocial key ids have no `#` and point at a stub. | Read all of these | P2 |
| Integrity proofs | FEP-8b32 `eddsa-jcs-2022`: JCS, no JSON-LD. Sent by Mitra, Streams, Hubzilla, Fedify and others; Mastodon verifies them from 4.7 | Verify, so relayed or forwarded objects need no refetch | P2 |
| LD signatures | Mastodon still sends `RsaSignature2017` | Ignore, and refetch from origin (we do) | — |
| Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 |
| `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 |
| Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 |
| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. | P2 |
| Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 |
| Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 |
| FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 |
| Search consent | `indexable` (missing = false), `discoverable`, `searchableBy` (FEP-268d, which takes precedence) | Honour all three; emit explicit `false` per persona | P2 |
| 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) |