Ghost joins the pasture with its ActivityPub service (Fedify) on the shared MySQL, routed by Caddy as Ghost's own proxy
does. scenarios/ghost.sh: follows both ways, the publication's titled Articles with their edit and deletion, likes and
boosts both ways, Ghost's reply and note, alice's post in its Network feed, both unfollows and statistics: 22 checks,
with no change to PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Owncast joins the pasture with federation turned on through its admin API; scenarios/owncast.sh has alice follow the
stream, receive its admin's message, like and boost it (both show in Owncast's admin), and unfollow: 13 checks, with
no change to PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
WriteFreely joins the pasture, built from its release with openssl beside it (it makes each blog's keys with the
command); scenarios/writefreely.sh has alice follow a blog and receive its post as a titled Article, its edit and its
deletion, then unfollow: 13 checks, with no change to PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
snac2 joins the pasture, built from its tag (images/snac2); scenarios/snac.sh drives its Mastodon API and reads its
own files, since its timelines lag behind what it has taken in: follows, posts, replies, likes, boosts, a poll, edits,
deletions, direct messages, the unfollow and statistics, 27 checks, with no change to PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mitra joins the pasture (peers/mitra.sh) on the shared Postgres; scenarios/mitra.sh drives its Mastodon API through
follows, posts, replies, likes, reposts, a reaction, a poll, edits, deletions, direct messages, the unfollow and
statistics: 28 checks, with no change to PrivaPub. Mitra carries FEP-8b32 proofs made with Ed25519, which PrivaPub does
not verify yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Federation:Relays names relays by their actor (or inbox) address; the instance actor follows Public at each, as Mastodon
subscribes, a minute after start and every six hours (asked again a day after no answer or a refusal, undone when a
relay is no longer named). What an accepted relay passes on comes to the federated timeline and nobody's home: a public
post it forwards (Activity-Relay), read again from its origin like any forwarded post, and a post it announces
(aode-relay), kept as its author's and never as the relay's boost. Nothing of a persona's is sent to a relay; sending
public posts there waits for the owner.
The pasture gains both relays (peers/relay.sh, peers/aoderelay.sh) and scenarios/relay.sh, 13 checks. The village
backlog is clean: 2574 checks pass, one known gap (Misskey's).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its
profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does
the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so
pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators)
is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them.
Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the
village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they
count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05
checks clean, 2454 of 2454.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Smithereen joins the pasture (tools/pasture/peers/smithereen.sh): its image on the shared MySQL, with imgproxy and a
file server behind Caddy, a JDK trust store with the pasture's CA, accounts from its signup form, and a password grant
for a local application. scenarios/smithereen.sh drives its VKontakte-like API: friends as mutual follows, wall posts,
comments, likes, reposts (quotes there), polls, edits, deletions, private messages, the unfollow and statistics, 30
checks. A post on someone else's wall never reaches PrivaPub, since Smithereen sends it only to servers whose actors
publish a wall: G-0009, waiting for the owner.
PrivaPub: a server description that failed (Smithereen serves no NodeInfo until it has a description) frees its week,
so the server's next arrival asks again instead of a week later.
The shared Postgres takes 400 connections: at 100 the village seed ran it dry (Sharkey's API answered 500, Misskey
dropped deliveries). The seeder records a follow that stands from an earlier seed when the step itself fails, so the
checker no longer expects that follower to see nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Two owner decisions of 2026-10-05, both recorded in ROADMAP:
- After a verified Move the personas following the old account follow the new one, in the same lists, and a mute or
block of the old account carries over, as Mastodon does it.
- A direct message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a ChatMessage,
the one place PrivaPub decides by a server's software (invariant 17). G-0008 is closed.
Mbin addresses its private messages to the recipient's profile page, so a Create addressed to a persona's /@name now
reaches the persona. Checked live: moves 8/8, Lemmy 0.19 30/30, Mbin 26/26 with messages both ways. The software
theory runs alone, since every test's peer shares 127.0.0.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PrivaPub dropped Move as an unknown type and showed no `moved` on accounts.
Now a Move is believed as Mastodon believes it: the moving account sends it
about itself, and the new account, read again from its own server, names it
in alsoKnownAs (now kept on remote accounts). The old account then shows the
new one as `moved` in the Mastodon API. The personas following it keep
following it: following the new account on their behalf would tell another
server about them, so that waits for the owner.
Checked live against GoToSocial (scenarios/moves.sh: an alias, a move, 6
checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Lemmy 0.19 (most of the threadiverse) and Mbin take a private message only
as a ChatMessage and refuse a direct Note. A direct message to one account
elsewhere that writes to us as ChatMessages now goes out as one: to it
alone, without a mention in its text, kept on the post (Post.AsChatMessage)
so the served copy and an edit match. No software name decides it.
Live against Lemmy 0.19: alice's answer to lemmyuser's private message and
another persona's message to lemmyuser arrive (29 checks). A first message
to an account that never wrote to anyone here is still a Note, which they
refuse; G-0008 keeps that open for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Most of the threadiverse runs Lemmy 0.19, whose release trusts only the
roots its rustls bundles. images/lemmy19 builds 0.19.20 from its tag with
reqwest's rustls-tls-native-roots added, and the pasture runs it as
lemmy19.test. scenarios/lemmy19.sh passes 27 checks with no change to
PrivaPub (communities, threads, comments, votes, its private message,
moderation) and one known gap: 0.19 takes private messages only as
ChatMessage and answers our direct Note 400, like Mbin, so G-0008 now
covers both and waits for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
NodeBB 4.16.1 runs in the pasture on the pasture's Mongo, set up by its
automated setup, with an API token written where NodeBB keeps them.
scenarios/nodebb.sh passes its 23 checks with no change to PrivaPub: a
category followed and its topic as a titled thread, replies both ways, a
follow of alice and her post there, votes both ways, an edit and a
deletion, a chat both ways, the unfollow and statistics.
NodeBB never federates a topic's lock, and its API follows an account
elsewhere only when named by its handle; both are in docs/INTEROP.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mbin 1.10.1 runs in the pasture (its image, a messenger worker, a RabbitMQ
of its own, its API limits raised), and peers/mbin_token.py gets mbuser's
token through the authorization-code flow. scenarios/mbin.sh: 24 checks and
one known gap, magazines both ways, titled threads, a Note to a magazine as
a microblog post, comments, favourites and upvotes both ways, a moderator's
lock, unlock and removal, the unfollow and statistics.
What it showed:
- Mbin sends a magazine's threads to its subscribers as the author's Create,
the magazine as its audience, never announced. A post whose group is
followed here and lives on the post's own server is now kept as if
announced; the same from another server is not.
- A moderator's lock is a bare Lock (and Undo{Lock}): LockHandler takes it
from the post's own server only.
- Mbin takes private messages only as ChatMessage and its actors say
nothing about it; PrivaPub never decides by a server's software, so this
stays open as G-0008 for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PieFed 1.7.17 (dockurr's image of the release) runs in the pasture with its
Celery worker on the shared Postgres and Redis, and scenarios/piefed.sh
checks it both ways: 29 checks, communities, titled threads, comments, votes
up and down, a community poll and a vote in it, private messages, a
moderator's lock, unlock and removal, the unfollow and statistics.
What it showed:
- PieFed sends a community's announces to the inbox of the Application at a
peer's root (as Lemmy serves its site actor) and to /inbox otherwise.
PrivaPub answered 404 at its root, so every announce went to an /inbox it
does not have. The instance actor now answers at / for ActivityPub
requests, unsigned under SecureMode as at its own address.
- PieFed keeps serving a thread its moderator removed, so the removal could
never be checked against the post's origin. A community on the post's own
server now speaks for it; one elsewhere still waits for the origin to say
the post is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Two owner decisions of 2026-10-05, recorded in the roadmap.
Replies passed on ("the fediverse is broken without"): a public or unlisted
reply from another server to a persona's public, unlisted or followers-only
post goes on to the persona's followers as its author's server sent it, as
Mastodon forwards it, never to the replier's own server, never for a
local-only or group post; its edit and deletion follow. Only an activity its
own actor delivered is passed on (Arrival.Raw), so nothing forwarded is
forwarded again. The town checks it as relay.reply cells (specs/relay-five:
882 checks pass); Mastodon takes a passed-on activity only with an LD
signature, which GoToSocial and Akkoma don't add, and the checker knows it.
Events: a persona joins another server's event with a Join and leaves it with
a Leave, both to the organiser only, through
POST /api/privapub/v1/statuses/:id/join|leave; the organiser's Accept or
Reject is routed by our join id and shows as privapub.event.participation.
Events by invitation or taken on another site are refused before anything
is sent. Mobilizon's scenario joins and leaves an event (28 checks) and keeps
one for decePubClient's e2e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Funkwhale 2.0.11: the API, a Celery worker with its beat and the front's nginx sharing one volume, on the shared Postgres
and Redis, trusting the pasture's CA through REQUESTS_CA_BUNDLE; fwuser and its OAuth token made by its own tools.
scenarios/funkwhale.sh passes its 16 checks: alice follows a channel, a track uploaded to it arrives as Audio with its
file and duration, its deletion reaches PrivaPub, the unfollow, statistics.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
decePub's e2e runs append to out/client.jsonl. The report kept the newest row per test and account, and the backlog
every row, so a failure from a run that picked other accounts stayed in both after later runs passed. Each client
cell now counts as its latest run left it (check.latest_client: the rows within an hour of its newest).
The backlog comes from a fresh village on today's build: 2556 checks pass and 4 fail, three of them peer-to-peer
counts, and one GoToSocial losing a status from its cache (an Update handled as a Create; a restart heals it),
recorded in INTEROP.md rather than as a gap, which would hide our own edits failing there. The community vote and the
Lemmy thread that failed before were data from older builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A remote post's canReply, canLike and canAnnounce (with the older always and approvalRequired) are kept beside
canQuote and judged for each persona: let in at once when the rule names the public, the persona, the author's
followers while it follows the author, or the accounts the author follows while the author follows it; asked first
when only the manual list names it; refused (422) otherwise. Asked first, a ReplyRequest, LikeRequest or
AnnounceRequest with the interaction as its instrument goes to the author alone, and the interaction waits
(privapub.approval: pending). The author's Accept brings an authorization, verified on the author's origin as naming the
interaction and the post; the reply then goes out with replyAuthorization, the boost with announceAuthorization, the
like with likeAuthorization. A Reject leaves the reply ours alone and takes a like or a boost back. As a third party, a
reply a policy does not let in at once is kept only with an authorization that verifies. Clients see the rules as
GoToSocial's interaction_policy.
Checked live against GoToSocial 0.22.1: the scenario's nine new checks pass (64 in all), a reply and a like approved
through its interaction requests and a boost refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Gancio 1.28.2 on its own sqlite, its config.json written before the first start (without it Gancio waits in its setup
wizard), trusting Caddy's CA through NODE_EXTRA_CA_CERTS; fediverse replies are kept as event resources. Its one
Application actor publishes the agenda, and scenarios/gancio.sh passes its 17 checks with no change to PrivaPub: the
follow, an event with its start, end and place, a reply kept as a resource, the edit and the deletion, the unfollow,
statistics.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mobilizon 5.2.4 on a PostGIS of its own (it needs the extension, which the shared Postgres has not got), trusting
Caddy's CA through the bundle mounted over certifi's and castore's files (hackney trusts only those), with geocoding
pointed at a closed local port. scenarios/mobilizon.sh passes its 23 checks: alice follows a group; the event its
organiser makes arrives as an Event with its start, end and located place; 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. An event made through Mobilizon's API without options has its comments closed, so
the scenario opens them. No RSVP yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Lemmy keeps the ids of the activities it received and never answers one again, so resending the same Follow could not
heal a follow whose Accept it lost: the village's community follow stayed pending through two resends. Each resend is
now the same follow under its own id (<follow id>-again-<n>), and an Accept naming any of them answers the follow;
the Undo still embeds the follow, so servers match it by its actor and object. Sent this way, the stuck follow was
accepted at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Friendica 2026.05 on the shared MySQL and Redis, with its worker daemon as a sidecar. friendica_settle repairs what
its install leaves: the system user has no name, so the system account that signs its fetches is never made; the web
container cannot see the sidecar's daemon, so a queued job waits for the five-minute cron unless the daemon is
declared running; its log needs a file and debugging on. Accounts are saved through its API with locked=0 (a number:
"true" reads as 0, unlocked), which makes them soapbox pages that take followers without following back, and each
one's outbox is read once: a Follow that reaches an account Friendica has not cached makes it fetch the account from
itself, signed, and checking that signature recursed for five minutes, holding PrivaPub's first follow past its timeout.
scenarios/friendica.sh passes its 25 checks from a clean install: follows and unfollows, posts (a titled one with its
title), comments, likes, Friendica's dislike as a downvote, boosts, edits (through its web editor: its Mastodon API
never federates one) and deletes, both ways.
The town gets a Friendica driver (HTTP Basic, its MySQL read through mysql_json, edits in the web editor, no bookmark
on a reply) and specs/friendica-pair.json, which passes its 275 checks. On the way:
- the checker knows Friendica's thread model: a non-public reply reaches an account only under posts it holds, and a
Friendica account's non-public reply in a thread another server owns reaches nobody else there;
- the seeder answers a follow request the target still holds whatever the follower's server says: Friendica reports a
follow of someone already following its account as made at once (and shows that persona's followers-only posts
while a locked persona still holds the request);
- the selftest skips polls where a platform has none; the shared MySQL helpers move to peers/shared.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A thread's server passes on what happens in it, signed with its own key: Mastodon forwards the replies to its
accounts' posts and their deletions, Friendica every activity in its threads. PrivaPub answered them 401, which also
tells a sender its signature failed. Now they get 202 and nothing in them is believed: a forwarded Create or Update is
taken as its object reads at the actor's origin, a Delete of a public or unlisted copy once that origin answers 404 or
410 (RemoteActorService.IsGone; FederationHttp remembers the status of a refusal), anything else is let go, and our own
activities coming back are ignored. A forwarded copy has its own dedupe key, so one that failed never hides the
author's own delivery.
A reply in the thread of someone followed here is kept, as Mastodon keeps them. Mastodon delivers a reply to the
followers of the account it answers; PrivaPub dropped those as unaddressed, which the pasture showed: the outsider's
reply its Mastodon scenario said was never delivered had been, and was thrown away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Pixelfed 0.14.4 (FrankenPHP with Horizon and the scheduler, on the shared
Postgres and Redis) and WordPress 6 with ActivityPub 9.3.1 (on a new shared
MySQL, WP-Cron run by a sidecar) are peers now, each with its scenario:
Pixelfed 26 checks (photos both ways with alt text, its place arriving as
Rome with its coordinates, comments, likes, a boost, edits and deletes),
WordPress 17 (an Article with its title, our reply, like and boost kept as
comments of their kinds, its edit and removal). Pixelfed has a town driver
and a pair spec: 145 checks pass, 2 expected (G-0007).
What they showed: Pixelfed names our posts by their page (fixed in
26dac40); on PostgreSQL its migration making caption nullable never runs,
so every remote boost failed (the pasture applies it); it files a DM it
fetches as followers-only (G-0007, upstream); Passport refuses a token
whose user id equals its client's id. WordPress signs with RFC 9421
first, which PrivaPub now verifies (c5a69d2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
WordPress's ActivityPub plugin (and Ghost and Fedify) sign with RFC 9421
first and fall back to draft-cavage only after a refusal, so each first
delivery cost two requests and a 401 in our statistics. Now a request
carrying Signature-Input is verified as an HTTP message signature: its
covered components (the method and our own public target, the body's
Content-Digest), its created and expires, with the actor's RSA key under
PKCS#1 v1.5 or PSS. Deliveries and signed fetches both take it; the
ledger names the scheme (rfc9421:rsa-v1_5-sha256). What PrivaPub sends
stays draft-cavage, which every server reads. Ed25519 waits for FEP-521a
keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Lemmy's moderation reached PrivaPub only as removals. Now a remote
community's lock and ban, relayed in its Announce, apply too:
- a lock (Announce{Lock}, or commentsEnabled false on the post) refuses
replies to the thread, ours included, until Undo{Lock}; statuses say so
in privapub.locked;
- a ban of a persona (Announce{Block} with the community as target, or the
moderator's own Block sent straight to us, which is the community's ban
and never the moderator's block of the persona) shows as blocked_by on
the community and refuses the persona's posts and replies there until
the Undo.
The Lemmy scenario's removal was an expected failure only because it gave
up before Lemmy's 30-second batch; it now waits, and checks the lock and
the ban live (Lemmy refuses a lock or an unban without a reason): 29
checks, none expected to fail. G-0003 and G-0006 are closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
GoToSocial's and Mastodon's scenarios passed once on a fresh pasture only:
their locked follows ended followed, so a second run found no request to
reject, and Mastodon's ended with mastouser blocking alice_masto, so every
follow after it was rejected. Each now unfollows first, and Mastodon's
undoes its block (checking that its Undo{Block} reaches our blocked_by) and
its lock; a run cut short is undone at the start.
Mastodon's scenario also checks the thread backfill live: a reply by an
account nobody here follows is never delivered, and joins the thread once
alice_masto opens it, read from Mastodon's FEP-7888 context.
interop.sh keeps the results of the peers it does not run, so the report
merges a partial rerun. All seven scenarios: 276 pass, 0 fail, 1 expected
(Lemmy moderation, P7).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The context of a remote post showed only what PrivaPub happened to hold:
replies from servers nobody here follows were never seen, and only the
ancestors were ever fetched. Now a persona opening a public remote thread
queues FetchReplies for the post and its root, at most once an hour each.
The job reads the thread's own collection first (FEP-7888 `context`, which
Mastodon 4.5+ serves with every reply at any depth; posts or, as FEP-f228
allows, the activities that made them), and otherwise the post's `replies`
(PeerTube's `comments`) and the replies' own, two levels down. At most 5
pages and 100 posts a job, signed by the instance actor, never a persona;
each post is fetched from its own origin and stored through StoreContext,
so only public and unlisted ones are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A comment in a community was announced only as Announce{Create}, which
Akkoma, Mastodon and Misskey drop: their members never saw it (the village
found Akkoma's missing). A comment is now also announced as itself, as a
new post already was; Lemmy answers that form 400, harmlessly. Nothing is
decided by the follower's software.
The town's checker expects a followers-only quote to stay with the
followers, and G-0003 covers only Lemmy-hosted communities (their relayed
moderation), since relayed likes count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PeerTube 8.3.1 runs on the shared Postgres and Redis, transcoding off. Its
scenario passes 24 checks: a persona follows a channel; a new video comes as
the channel's Announce and reaches the persona's home as a boost, playable
through the media proxy with byte ranges; comments both ways thread; a like
and its undo count; renaming and deletion arrive; the unfollow; statistics.
The town's seeder also takes reruns on the same accounts in its stride: a
Lemmy community already made is found, a circle member already approved
asks nothing again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
decePub's e2e boost on Misskey undoes its boost and finds the count still
up: Misskey deletes the renote but its NoteDeleteService lowers only
repliesCount. Recorded as a peer gap, so the cell is a known failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Pleroma 2.10.2 runs in the pasture from its OTP release (libvips, the
dedupe question, and hackney pointed at the system CA bundle); its driver is
Akkoma's on its own container and database. The pair passes 241 cells, after
its first run showed Pleroma dropping Follows it could not verify yet.
The PrivaPub driver finds the group an earlier run made instead of failing
on the name; the seeder posts a quote or reply of a post from its own round
after that post.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Iceshrimp.NET 2026.1.2-beta runs in the pasture with AuthorizedFetch on;
its driver registers through the native API and takes Mastodon tokens from
its OAuth form. The pair passes 225 cells. A new Iceshrimp note reaches its
author's followers only, never an account it mentions or answers, until it
is edited (G-0004, peer): delivery cells now say when a server holds a post
only because it was addressed.
The checker credits a seeder's fetch to the object fetched, not to the reply
that needed it; the seeder skips the wait for an accept whose follow was
already accepted; the PrivaPub driver signs its roots in again when a run
reuses saved sessions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Lemmy, PieFed and Mbin relay their members' votes, and the undoing of
them, inside the community's Announce; PrivaPub dropped them all as
unsupported (G-0003, the Lemmy scenario's expected failures). A relayed
Like, Dislike or Undo from a community a persona follows is now handled
as if its actor had sent it. The community vouches for what accounts on
its own server do and for what is done to its own posts, as Lemmy trusts
it (refetching every vote would not scale); a vote from elsewhere on
anything else is believed only once fetched from its own origin.
Moderation relayed the same way is still open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Inbound Block was dropped as an unknown type (the pasture's last
Mastodon expected failure, G-0002). Now, as Mastodon does it: the
follows between the blocker and the persona end here too (nothing is
sent back; the blocker already ended its side), the blocker's posts and
notifications are hidden from the persona and kept out of its home, the
persona's posts are no longer addressed to the blocker by mention or
reply, and the relationship says blocked_by. Undo{Block} lifts it. The
block is kept in BlockedBy, unique per persona and blocker.
The Mastodon scenario checks blocked_by instead of expecting a failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PrivaPub now finds CDNs three ways, best first: the address ranges the
CDNs publish (Cloudflare, Fastly, Amazon CloudFront, Bunny, Gcore,
Imperva), downloaded daily by CdnUpdater and kept in CdnRangeSet; the
CDN's fingerprint in the responses it already gets from a server
(EdgeHintsHandler on the federation client); and the networks that carry
only a CDN. The fixed ASN list is gone; ASNs shared with plain hosting
(AWS, DataPacket) no longer hide a server. A server's Geo records the
CDN, its domain and how it was found, and weekly snapshots now keep the
city and coordinates too.
Servers through time (ServerPlaces): /instances/:host/history lists a
server's weekly snapshots, a CDN-fronted server's geo names the CDN's
domain and where the server was before it (before_cdn), and
/api/privapub/v1/cdns and /cdns/:domain group servers by CDN with week
by week who joined and who left. Owner decisions recorded in ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A Note's `location` that is a Place with coordinates (Pixelfed sends
them as strings) is kept in the new Post.Place and shown as
Status.privapub.place {name, latitude, longitude, country}; an edit
replaces it. An Event's location stays its own `places`, and nothing
renders a place back out. Closes the INTEROP P2 Pixelfed location gap
and the ROADMAP long-tail item.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
/api/privapub/v1/instances/:host gains `geo`, the public projection of a
server's place already decided for public server locations
(PublicGeo.Project): city, coordinates and network for servers reporting
at least 10 users and not behind a CDN, the country otherwise, the CDN's
name for a CDN-fronted one, with DB-IP's attribution. `?host[]=`
answers up to 40 servers at once, and this server describes itself:
its host's address located once a day (SelfLocation), or
Statistics:Geo:Self when the owner sets it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Owner decision 2026-10-04: SecureMode on once the pasture passes with it.
- Both clean pasture passes were run over all six peers:
- normally: 246 passed, 0 failed;
- with Federation__SecureMode=true: every federation check passed. The only failures were four checks expecting an
unsigned GET to get 404 or 410 where SecureMode answers 401. Those checks now go through `unserved` and
`gone_unsigned` (lib/interop.sh), which expect 401 when SecureMode is on.
- Circle posts now reach their member on GoToSocial and Mastodon, and survive Mastodon's signed refetch, as does a
followers-only post. The GoToSocial expected failure is gone.
- appsettings.Production.json turns SecureMode on.
- The deploy now checks that an unsigned GET of @thepra answers 401 and that a browser is redirected. It reads
@thepra's discoverability through the Mastodon API, since the actor is no longer readable unsigned.
- docs/INTEROP.md (Mastodon, GoToSocial), CLAUDE.md and ROADMAP updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
Sharkey 2025.4.7 is the Misskey peer under another name, image, database, Redis db and
home. Its scenario is Misskey's plus edits both ways and the FEP-e232 quote tag: 40 checks
pass.
Akkoma 3.20.1 publishes no image, so tools/pasture/images/akkoma installs its OTP
release, pinned by checksum, and appends Caddy's CA to the CA bundles the release ships.
Its scenario has 44 checks, which pass three runs in a row:
- follows, posts, CW, followers-only posts and the published time;
- replies, likes, boosts and their undos;
- EmojiReact both ways and withdrawn;
- DMs, polls, quotes, media, edits with history and deletes, both ways;
- unfollow, block, unblock and statistics.
The two bugs, both fixed:
- Followers-only posts arrived as DMs on Pleroma and Akkoma. They call a post private
only if an address in `to` contains "/followers" or its cc is not empty. Ours is
/groupies, and a post mentioning nobody had an empty cc. The followers collection is now
named in cc as well, which tells nobody anything new.
- Akkoma's open polls showed as ended and refused votes. Akkoma carries an open poll's
end in `closed` and sends no `endTime`. A `closed` in the future is now read as the end.
Neither of these is a PrivaPub bug:
- Akkoma's Linkify never takes @user@host.test for a mention, so its DM addresses alice
with to[].
- Its API never reports a remote blocker as blocked_by, so the block is read from its
database.
Also in this commit:
- The rate limits are configuration (RateLimits: AccountsPerMinute, InboxBurst,
InboxPerTenSeconds), with the old values as defaults. The pasture raises the accounts
limit, which back-to-back runs from one address had hit.
- A new Lemmy never sends what it queued for a server before it started that server's
send worker, so the scenario waits for the worker before its first follow.
All six peers in one clean pass: GoToSocial 54 (+1 expected), Mastodon 49 (+2), Misskey
35, Sharkey 40, Akkoma 44, and Lemmy 20 (+3) once the worker wait was added. 642 tests
pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
peers/lemmy.sh runs the Lemmy 1.0.0-beta.2 backend on the shared Postgres. It trusts
Caddy's CA through SSL_CERT_FILE and reaches the pasture through
DANGER_FEDERATION_ALLOW_LOCAL_IP. Lemmy 0.19 cannot join: its rustls trusts only its
bundled roots. LEMMY_LOG sets RUST_LOG.
scenarios/lemmy.sh drives Lemmy through its v4 API. 20 checks pass, three runs in a row:
- communities both ways;
- a titled thread each way, and alice's mention of a Lemmy community becoming a thread
there;
- the Lemmy community's announce reaching alice's home;
- comments both ways and alice's like as an upvote;
- private messages both ways;
- statistics.
Two expected failures: votes, which Lemmy sends only through the community as
Announce{Like|Dislike}, and a moderator's removal. Both belong to P7.
What it showed, in docs/INTEROP.md:
- Lemmy 1.0 keeps its user's thread in a remote community pending until the community
announces it back. It clears the flag before answering that echo 400 ("Object is not
remote"). Leaving the author's server out of the Announce, as tried here, left every
such thread pending, so GroupDistributor now says why the echo stays.
- Every bare Announce{object} is answered 400, as Lemmy answers its own compatibility
Announce(Page).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
peers/misskey.sh runs Misskey on the shared Postgres and Redis. Its config is generated with
the pasture's private networks allowed and a setup password, it trusts Caddy's CA through
NODE_EXTRA_CA_CERTS, and the first account it makes is the scenario's user. A new Misskey
federates with nobody, so the setup also turns federation on.
scenarios/misskey.sh adds 35 checks, all passing, as listed in docs/INTEROP.md. They cover
posts, CW, MFM source, replies, unicode reactions both ways and their withdrawal, likes as
reactions, legacy quotes both ways, polls, specified notes, media, deletes, unfollow,
block and statistics. MISSKEY_NAME and MISSKEY_IMAGE are there so Sharkey can reuse the peer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
peers/mastodon.sh runs Mastodon's web and sidekiq containers on a shared Postgres and Redis
(peers/shared.sh). They trust Caddy's CA through SSL_CERT_FILE and reach private addresses
through ALLOWED_PRIVATE_ADDRESSES. Its users and tokens come from tootctl and rails runner.
scenarios/mastodon.sh adds 49 checks, as listed in docs/INTEROP.md: follows, posts,
replies, likes and boosts with undos, DMs, polls, FEP-044f quotes both ways, media through
the proxy, edits, deletes, locked follows, a circle request, reports, blocks and statistics.
Two are expected failures:
- inbound Block (P7);
- circle posts. Mastodon 4.7 loses the recipient of deliveries to its numeric
/ap/users/<id>/inbox and then drops a post that names no local account. The fix on our
side changes what a circle reveals, so it waits for the owner.
GoToSocial and Mastodon together: 86 passed, 0 failed.
The pasture now copies Caddy's root certificate reliably, readable by the peers, and
rebuilds the bundle each time. The CA directory is mounted shared (:z), because a private
:Z label locks out every container but the last. pfetch reaches PrivaPub's own https URIs
through Caddy. PRIVAPUB_ENV passes settings to PrivaPub, which scenarios/crawler.sh uses to
check the opt-in crawler against Mastodon: it visits, describes and reads the peers list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
Tests through the real routes of PeasantsController, WellKnownController and
UsersController, on the whole server under test (34 new tests):
- FederationGetTests: the actor document (activity+json, SPKI key at
#main-key owned by the actor, sharedInbox, published = PublishedOn's day,
no creation date, no root); ld+json; browsers sent to /@name; Vary: Accept;
/users 301; the outbox's totalItems and ?page=true&max_id paging across
the 20-item boundary, boosts as Announces, and no followers-only, direct,
located, federated-copy or deleted post; /groupies and /stalking naming
nobody; /trophies (public pins, newest first) and /tattoos; /scribbles
(public and unlisted 200, browsers redirected, followers-only, direct and
located 404, deleted 410 Tombstone); a circle post only for a signed member
or its instance actor; a circle's /groupies, /flock and /wardens only for
members; /grunts create- and announce- ids; /parrot-licences 200, revoked
410, wrong author 404; secure mode's 401 for every unsigned GET but the
instance actor's.
- WellKnownTests: WebFinger by acct:, @-prefixed, bare, upper-case and actor
URI; other domains, unknown names, a root's login name and no resource;
the instance actor, a community, a circle (answered: current behaviour);
NodeInfo links, 2.0 and 2.1 naming no root, unknown versions 404; usage
counting only public, unlisted, non-boost local posts (Exclusive).
- InboxRouteTests: all three inboxes accept a signed delivery and refuse
junk (400), unsigned (401), a bad Digest, a two-hour-old Date, a signature
for another host and a swapped body (401); an unknown persona's /mouth is
404; ld+json with the ActivityStreams profile is accepted; a signer whose
actor answers 503 gets 503 with Retry-After; the 301st unsigned POST from
one address is 429 while a signed server from it is not.
- PersonaSeparationHttpTests: with a sibling persona and its community on
the same login, every GET under /api (filled with the persona's ids) plus
search, lookup and relationships, and a crawl of everything federation
publishes about the persona (actor, outbox pages, collections, scribbles,
grunts, WebFinger, NodeInfo, /@ pages), never name the sibling, its
community or the login.
Fixed:
- A circle's /groupies told anyone how many followers (members) it has,
while its /flock and /wardens were already for members only; it now
answers 404 to anyone but a signed member or a member's instance actor.
- WebFinger answered 404 to a bare user@domain or @user@domain resource,
which Mastodon, GoToSocial and Pleroma all accept; it now treats them as
acct: (noted in docs/INTEROP.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
- A remote `summary` is a content warning only on a Note or Question, or when `sensitive` is set. On Articles,
Events, Videos, Pages and Audio it is an excerpt (WordPress teasers, Mobilizon dates and places, Mbin titles) and
is now kept as `Post.Excerpt` instead of hiding the post. `Post.ObjectType` records the remote type, and the
Mastodon API shows a remote non-Note object's title above its body again.
- Persona usernames must match `^[a-z0-9_]+$`, as groups already did; a name outside it was unreachable from
Mastodon and Misskey.
- `postingRestrictedToMods` is defined in our JSON-LD context (Iceshrimp.NET drops undefined terms).
- `Vary: Accept` on actor and object URLs.
- Owner decision: blocks federate. A block sends `Block`, an unblock `Undo{Block}`; checked live against GoToSocial.
- Owner decision: a persona's and a group's `published`, and the day in new ids, is a random day up to two weeks
before creation, so personas made the same day no longer share a date. Migration _007 gives existing ones theirs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
docs/INTEROP.md collects five research passes from 2026-10-01: Mastodon 4.7 and GoToSocial 0.22, the Misskey and
Pleroma families, the threadiverse (Lemmy 0.19/1.0, PieFed, Mbin, NodeBB), media and long-form (PeerTube, Loops,
Pixelfed, WordPress, Ghost, events, audio, books, Threads, Flipboard, Bridgy Fed), and cross-cutting FEPs and
signatures. Each claim was checked against source code or live fetches, with dates and versions.
It is checked against our own code: what is already right, three cheap things that are wrong today (summary read as a
CW on every type, unchecked usernames, an undefined context term), what the rich client needs kept (a post kind with
typed payloads, raw capture and provenance for the details view), and the six privacy choices the owner has to make.
The roadmap's P5 becomes P5 to P8, ordered by that evidence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB