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
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
/api/v1/conversations answered one page, never unread, its read endpoint
did nothing, and DELETE was missing; each conversation cost a query per
member. Now each conversation keeps its newest post (DmGroup.LastPostId,
set as posts arrive, learnt once by migration _013) and pages by it as
Mastodon does, and each persona's ConversationState holds what it read and
what it took off its list:
- unread when someone else wrote last, after what the persona read;
- read marks it so, and writing in a conversation reads it;
- DELETE takes it off the list until a newer message brings it back.
The list reads its states, newest posts, members and accounts in a few
queries per page.
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
Mastodon's streaming API: /api/v1/streaming as a WebSocket (streams
subscribed in the URL or by message) and /api/v1/streaming/{stream} as
server-sent events, with health and the URL advertised. The user stream
tells posts reaching the persona's home (not those an exclusive list keeps
apart, which its list stream tells), notifications, edits and deletions;
public, hashtag and list streams tell what belongs in them. An in-process
hub carries ids only; each connection maps a post or a notification for its
own persona as it sends it, so nothing it may not see, or whose author it
blocked or muted, goes out. A deletion reaches only the streams that showed
the post. The token comes as access_token, header or WebSocket protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
/api/v2/search now searches statuses by words for a signed-in persona, in
Mastodon's scope: what it wrote, boosted, favourited, bookmarked or was
named in, and public posts of authors who let themselves be indexed
(indexable, off by default). Newest first, only what the persona may see,
through a text index over the posts' words in every language alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
trends/tags, trends/statuses and trends/links replace the stubs. Hashtags
and links rise with the number of different authors using them today
against the week before, two at least; posts with their favourites, boosts
and replies, halving every twelve hours. Only public posts PrivaPub already
holds count, nothing behind a content warning, and a post trends only if
its author is discoverable. Computed at most every ten minutes.
The directory lists discoverable accounts by their latest public post, or
by the day they say they joined: a persona's published day, never its
creation, with ties broken by a hash of the name, so personas made together
are not told apart by their order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
tags/:name shows the tag's last seven days in public posts PrivaPub holds
(uses and authors, as Mastodon gives them) and whether the persona follows
it; follow, unfollow and followed_tags replace the stubs. A public post that
is neither a boost nor a reply comes, as it arrives, to the homes of those
following one of its tags. Nothing is fetched for a followed tag and no
other server hears of it. Posts are indexed by tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
POST /api/v1/statuses with scheduled_at no longer posts at once: the post is
kept as asked (at least five minutes ahead; 300 waiting, 25 a day, as
Mastodon allows), its media kept from the janitor, and a PublishScheduled job
publishes it at its time as the persona. scheduled_statuses lists, moves and
drops them; a moved post's old job finds it not due. Idempotency-Key holds
for scheduling too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mastodon's v2 filters replace the empty stubs: a filter's title, contexts,
action (warn, hide, blur) and expiry, its keywords (whole words or not,
taken as JSON objects, listed or numbered form fields, with id and _destroy
on update) and its statuses, each with their own endpoints; the v1 API is
the same filters seen keyword by keyword. Every status a persona reads
carries the filters it matches in `filtered` (a boost as what it boosts),
matched as Mastodon matches: warning, title, text, poll options and media
descriptions. Clients apply context and action. Filters never federate, and
go with a deleted persona.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A server can take a Follow with 202 and drop it afterwards, as Pleroma does
while it cannot fetch our actor; the request then stayed pending for good.
Following again now sends an unanswered request once more, the same
activity, at most once an hour (a delivery's `again` key).
accounts/search takes following=true: only accounts the persona follows,
by the start of their name, display name or server, never resolved; a
client fills a list with it. "already take" becomes "already taken".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mastodon's lists replace the empty stubs: CRUD, members (only accounts the
persona follows; a follow that ends takes its memberships with it),
accounts/:id/lists, and timelines/list/:id from the persona's home entries
with the replies policy (followed, list, none; self-replies and replies to
the persona always). An exclusive list's members stay out of home. Lists
never federate, and go with a deleted persona.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
tools/pasture/town/ (run through tools/pasture/town.sh) seeds a fake
community across every running peer and checks that all of them, and
PrivaPub, agree on what happened:
- drivers per platform on four dialect bases (Mastodon API, Misskey API,
Lemmy API, PrivaPub with /clientapi), each with a selftest against
its own server; what a server holds is read from its database, never
by making it fetch;
- a deterministic generator (specs/village.json: 23 accounts on seven
servers, roots with several personas, circles and communities, a
cross-server follow graph, posts of every kind and visibility, reply
rounds, likes, boosts, reactions, votes, edits, deletes, blocks,
mutes and a report) and a seeder that keeps a ledger of what happened;
- a sweep that expects delivery and confinement per server, what each
account sees, counts, threads, edits, deletes, follows and privacy
rows (sibling keys, published days, canary root credentials in every
peer's database, located posts that never leave), with what the peers
do on purpose modelled (Misskey drops orphan replies, Lemmy keeps only
community content, edits go to the post's own audience);
- known gaps (gaps.json) turn failures into xfail and passes into xpass;
a self-contained report.html, and docs/INTEROP-BACKLOG.md.
The pasture moves to a public-looking subnet (peers with no private
address switch can join), takes PASTURE_PORT when 6971 is in use, adds
peers to a running pasture (run.sh add, Caddy recreated with its CA
kept), removes its volumes on down, writes every scenario check to
out/scenarios.jsonl, serves decePub as decepub.test for its e2e tests,
lifts GoToSocial's and Lemmy's own rate limits, trusts Caddy in
Mastodon (TRUSTED_PROXY_IP) and gains Hollo (Fedify), whose one login
owning several accounts is the nearest peer to PrivaPub's personas.
The first village found the four PrivaPub bugs fixed in the commits
before this one; the second run, on the fixed server, passes 2319 checks
with 11 failures left, all between peers or from Lemmy's send worker,
which the seeder now warms up first. ROADMAP records the owner's
decisions of 2026-10-04 (the town, and P9 back from the cut list).
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
The plan for the public /stargazing statistics showed per-server
activity only as order-of-magnitude buckets and only for servers
reporting at least 10 users. The owner never intended either
restriction; both are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The public projection of a server's place showed only the country for
servers reporting fewer than 10 users. The owner never decided that
threshold: every located server now shows its city, coordinates (0.1°)
and network, and a CDN-fronted one still shows only its CDN, since the
address reached is the CDN's edge. Statistics:PublicCityMinUsers is
gone; the ROADMAP decision on server locations, CLAUDE.md and the
/stargazing explainer are corrected.
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
The first-party client signs in on /clientapi and exchanges that JWT on
/oauth/token (RFC 8693, subject_token_type jwt, avatar_id) for one
persona's Mastodon token. Only the seeded public application `decepub`
holds the grant; RootJwtSubjectToken validates the JWT through RootJwt,
which JwtBearer now shares (signature, lifetime, ban, deletion, session
stamp). The issued token names the avatar, never the root.
Owner decision recorded in ROADMAP; it supersedes "moving decePubClient
onto the Mastodon API is out of scope".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
- FEDERATION.md:
- It said only GoToSocial was tested live. It now lists all six pasture peers, run with signed fetches required as
production runs, and what is checked both ways.
- FEP-044f, FEP-9967, FEP-c0e0 and FEP-5feb moved from planned to supported.
- indexable and discoverable are described as the settings they are.
- ROADMAP: v1.19.1 deployed and verified, v1.20.0 (phase 4).
- Pasture, Akkoma: its like and boost count gets two minutes. In the final clean pass, all six peers with SecureMode on
gave 245 passed, 1 failed, 4 expected. The failure was this count, which Akkoma's job queue was slow to update
under the load of six peers; two reruns gave 44/44.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
- A community's announces resolve, as FEDERATION.md and ROADMAP always said Announce ids do. GroupDistributor keeps
each one it sends (GroupAnnouncement, unique by id). /grunts serves it while the post it carries is shown and 410
after, and also serves the announce-{postId} ids the group outbox lists, which now keep a stable `published`
instead of the time of the fetch.
- A persona's boost points at the boosted post: its `url` in the Mastodon API is the boosted post's page, and a browser
following the boost's id is redirected there instead of getting JSON.
- /tags/{tag}, where every Hashtag link we send points, is now a public page of this server's public posts with that
tag, with the same strict CSP and noindex as the profile pages.
- Grouped notifications (/api/v2/notifications, its unread count, a group, its accounts and dismiss). We advertise
api_versions.mastodon = 7 so clients show quotes, and clients that trust it call these; they answered 404. Likes and
boosts of one post group together, as do follows within an hour. The version string stays 4.2.0 until streaming
and Web Push exist.
- A remote account's follower, following and post counts are what its server publishes. AccountCountsJob reads its
collections' totalItems from its own origin, signed by the instance actor, at most daily and only after the account
was fetched, never when someone looks. They used to be 0.
- The other ids that do not resolve are documented as such: Update, Delete, EmojiReact, QuoteRequest and its answers,
Flag, Ignore and poll votes.
676 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
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
Owner decisions (2026-10-04, recorded in docs/ROADMAP.md): production runs everything that is built, and nothing waits
on a person running a command.
- Geolocation updates itself. GeoUpdater, a hosted service, checks daily whether each DB-IP Lite database was built this
month. If not, it fetches this month's, or last month's early in the month. It installs a file only once it opens as
the right kind of database, then swaps it in atomically, and the locator reloads at once. Lookups now run under the
lock, so a reload can no longer dispose a reader mid-lookup. The systemd timer, its script and their setup.sh lines
are gone: the root step they needed never happened, and none is needed now. /stargazing names the database in use.
- The admin CLI runs after the app is built, with every service and nothing started.
- `create-root <login> [--admin]` takes the password on stdin; it is how the first login is made while sign-up is
closed.
- `smoke <persona>` keeps the root `deploy-smoke` and an undiscoverable persona, and gives the root a new password
on every run.
- The deploy signs in as @thepra. It runs the CLI, gets a token through the real OAuth flow (tools/smoke/oauth.sh,
moved out of the pasture's privapub_token, which now uses it), checks the signed-in API and that @thepra is
undiscoverable, then revokes the token. PRIVAPUB_SMOKE_TOKEN is gone.
- The deploy also fails when:
- NodeInfo and the instance API disagree about registrations;
- /stargazing does not say the crawler is on;
- the geolocation databases are missing or more than 40 days old.
- The crawler is on in production, seeded with ten large servers of different kinds. FEDERATION.md now describes it
and how to opt out.
- One registrations switch (Registrations:Mode, default Invitations; Open in tests and the pasture). It is read by
open sign-up (403 when closed), NodeInfo `openRegistrations`, and v1 and v2 of the instance API, so they can no longer
disagree. Before, NodeInfo said open and the instance API said closed. Group invitations always work, so
invites_enabled is true.
- A persona edit through /clientapi no longer resets what the Mastodon API set (discoverable, locked, quote policy…):
the theme is merged into the settings instead of replacing them.
650 tests pass. The deploy's smoke step was rehearsed against the pasture's PrivaPub.
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
InteractionEvent records one interaction with a remote server: its channel (recv, in, out,
http, preview, crawl), activity and object type, outcome and reason, status, latency, wait,
bytes, attempt, audience, local actor kind, inbox, signature, features and the object's age.
IInteractionLedger.Record never blocks and never throws: events go into a bounded channel
of 10k, a full channel drops and counts, and a hosted service writes batches of up to 1000
every two seconds.
Privacy, as decided by the owner:
- no persona, root, group or activity id, inbox URL, actor URI or sender IP is stored;
- distinct accounts are counted with an HMAC keyed by a per-day salt (InteractionSalt,
upserted so restarts agree, never created for a past day);
- the local actor kind survives only on public and unlisted traffic;
- a host claimed by an unverified sender is kept only if it is already known.
Traffic caused by reading is only counted per day (InstanceDay.Reads, ServerDay). Indexes:
a 90-day TTL on events, unique day rows, and a TTL safety net on salts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
- Our public and unlisted posts state interactionPolicy.canQuote. The policy comes from the post, then the persona
(`source[quote_policy]`: public, followers or nobody; default public as the owner chose), and is always nobody for
followers-only posts and DMs.
- QuoteRequests are answered with Accept{result} naming a parrot-licence at /peasants/{name}/parrot-licences/{id}
(the route name the owner chose), or with Reject. Followers-only checks that the requester really follows.
- A licence is a QuoteAuthorization naming both posts; revoking it (POST /api/v1/statuses/:id/quotes/:quoting_id/revoke)
marks it 410, sends Delete{licence} to the quoter and the persona's followers, and revokes our own copy of the quote.
- A quote that arrives with one of our licences is accepted only if that licence is ours, unrevoked and names exactly
that quoting post. One persona quoting another gets a licence too.
- Mastodon API: quote_approval for our posts (automatic, followers, current_user), `quote_approval_policy` when posting,
PUT /api/v1/statuses/:id/interaction_policy, `source.quote_policy`.
Checked live: GoToSocial still accepts our posts with the policy stated, and leaves likes, replies and boosts open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- The media proxy streams ranged requests from the origin (passing the range on, never caching), downloads and caches
whole files otherwise, and serves cached files with range support. A PeerTube video is never fetched whole for one
viewer, and clients still never contact the remote host.
- PeerTube's fragmented MP4 files inside an HLS entry are read as variants, so HLS-only instances play too.
- A remote Video or Audio post becomes one playable Mastodon attachment: the best MP4 up to 720p that carries both
sound and picture, with its poster and duration; the card is kept only when nothing is playable.
- nginx: /media/proxy/ with proxy_buffering off and a 600 s read timeout (applied on the box, with a backup).
Checked live: a GoToSocial image through the proxy answers 206 with exactly the asked range when streamed, 200 when
cached, and 206 with the right Content-Range from the cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post
is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent
quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts
and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed
from content when the real quote is shown.
- Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until
an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted
the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts
are delivered to the quoted author too.
- Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote
policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7.
Checked live: GoToSocial's author-only quote policy is respected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- A public post that links to a page gets a preview card: from the post's own data first (FEP-8967 Link preview, the
object's image, title and summary); otherwise the server reads the page once, 0-60 s after the post arrives, never
when someone reads it. OpenGraph and Twitter tags give title, description and image; one LinkPreview per address
is cached for 7 days and shared by the whole server, so a fetch never points at a persona.
- Lemmy link posts, which carry no title or description, get them filled in.
- The page fetch uses the guarded client (public addresses, three redirects, HTML only, first 512 KB).
- Federation:FetchLinkPreviews switches page fetching off.
- Local public posts get cards too.
Checked live: a link to a GoToSocial profile page becomes a card with its title, description and proxied image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- Incoming reactions in all three shapes: Misskey and Sharkey Likes carrying an emoji (`content` or
`_misskey_reaction`), Pleroma, Akkoma and Iceshrimp.NET EmojiReacts, and their Undo. Unicode reactions are one
grapheme; custom ones need their Emoji tag and keep its image; `:name@host:` is read as `name`. A Like whose
content is a heart stays a favourite.
- Reactions are kept per account and emoji on our posts and on remote ones we hold, and the author of a local post
gets a `pleroma:emoji_reaction` notification with the emoji.
- Personas react through Pleroma's API (PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji, GET for the list),
which sends an EmojiReact (or its Undo) to the post's author.
- Statuses carry `emoji_reactions` (read by Phanpy) and `pleroma.emoji_reactions`, with counts and whether the viewer
reacted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in
Status.emojis and Account.emojis.
- Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and
image descriptions (a locked GoToSocial account no longer shows as open).
- Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed;
our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become
replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every
three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST
/api/v1/polls/:id/votes.
- An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision.
Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- When a sender's key cannot be fetched because its server timed out or answered 5xx, the inbox answers 503 with
Retry-After: 300 instead of 401, so Mastodon 4.7 retries rather than switching to RFC 9421 signatures we do not
verify yet. The fetcher's failure cache now remembers whether a failure was temporary.
- Follow, Like, Block, Accept, Reject and Undo ids are deliberately not dereferenceable: serving them would publish
who follows, likes and blocks whom. They are always sent with their object embedded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- Dislike and its Undo are kept as downvotes (Lemmy, PieFed, Mbin, Friendica) and shown with favourites as votes.
- ChatMessage (Lemmy 0.19, Mbin, PieFed to those two) arrives as a direct message.
- A deleted object's id is remembered for 90 days, so a Create that arrives after its Delete cannot bring the post
back; the post's ObjectRecord goes with it.
- A Join of one of our objects is answered with Ignore, as FEP-8a8e asks of a server without RSVP.
- A 503 with Retry-After is waited out like a 429 instead of counting as a failure of the host (GoToSocial throttling,
Mastodon's temporary key failures).
- Status.privapub carries what a Mastodon Status cannot: object type, title, excerpt, cover, the author's source,
link, video, audio and event details, and up/down votes, with every media URL proxied.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- ObjectShapes reads `url`, `icon` and `image` as a value, a Link or an array; Markdown `content` (PeerTube) is
rendered; a missing `mediaType` is inferred from the extension or the attachment type (Bridgy); alt text comes from
`name`, `summary` or their language maps; thumbnails come from attachment `icon`, object `icon[]`, `image` or a Loops
`preview`; durations are ISO 8601 or seconds; per-attachment `sensitive` is kept; the language falls back to
`@context` `@language` (Pleroma); titles and excerpts are plain text; hashtags normalise with NFKC like Mastodon.
- Typed details on Post: Link (Lemmy link posts, Mbin `source` URLs, Mastodon 4.7 Link attachments with an FEP-8967
publisher preview), Video (PeerTube files with their streams, HLS playlist, poster, captions, chapters, licence,
channel, live state, comment policy), Audio (Funkwhale), Event (Mobilizon, Gancio, Hubzilla: times, zone, floating
time, places and addresses, online link, capacity, status), the cover image, and the author's own source text.
- Mastodon API: a card built from those details without fetching anything, an event's "when · where" line, attachment
thumbnails and durations, and no media file offered as a preview image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- ObjectRecord, one per stored remote object: the raw JSON (up to 256 KB, always hashed), delivered or fetched,
refetched from origin or not, the activity that brought it (or caused the fetch), shared or personal inbox, the
signature's key, algorithm and signed headers, received time, published and updated, the delivering activity's
@context, and up to ten later revisions from Update.
- Delivery details travel from InboxReceiver through the inbox job to the handlers as Arrival.Current.
- A host is described from its NodeInfo when we first hear from it, at most weekly (DescribeInstance job), never when
someone opens the details view.
- GET /api/privapub/v1/statuses/:id/provenance and /api/privapub/v1/instances/:host, with the extensions an object
used detected from its raw form (044f quotes, interaction policies, contexts, proofs, Misskey fields, MFM, FEP-8967
links, emoji, polls, language maps, url variants, Markdown content).
Checked live: a GoToSocial reply shows as delivered to the shared inbox, signed hs2019 with GoToSocial's fragment-less
key id, with its interaction policy detected, and gts.test is described as gotosocial 0.22.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
- 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
tools/pasture/run.sh starts PrivaPub, GoToSocial and Mongo on one podman network behind Caddy's internal CA, and
interop.sh drives both through their own client APIs: follows (one to a locked account), posts, CW, replies, likes,
boosts, DMs, edits, deletes and unfollow. All 25 checks pass, three fresh runs in a row.
- Federation:AcceptAnyCertificate joins the two test-network switches; startup refuses all three in Production.
- WebFinger falls back to http only when AllowPlainHttp is on.
- A bootstrap logger, so a failure before the host is built is no longer silent.
- P4 is ticked in the roadmap, with what has not been run live (Lemmy, a Mastodon circle member).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB