An upload went straight to libvips: whatever loader recognised the bytes ran (an SVG sent as image/png was rasterised),
nothing bounded how many pixels it would decode to (a small PNG could decode to gigabytes, three times over), a GIF
was loaded frame by frame and never resized, and all of it ran inside the request with nothing limiting how many at
once. A GIF was typed gifv but stayed a .gif, which a gifv player can't play; its metadata was kept; colours lost
their ICC profile without being converted; HEIC was advertised but the bundled libvips can't decode it.
Now:
- only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (every other loader is blocked);
- the header alone says how big an image would decode, refused above Media:MaxPixels (40 MP) or MaxFrames;
- a still image is shrunk on load, turned by its orientation and brought into sRGB (thumbnail), then written without
metadata, a profile picture the same way;
- an animated GIF becomes a looping silent H.264 mp4 typed gifv, as on Mastodon (PostMedia.Kind keeps it a gifv),
and a remote GIF is an image;
- processing runs Media:Concurrency at a time, and uploads have their own rate limit per credential;
- HEIC and HEIF are no longer offered.
Tests: only the upload formats load, the header tells the size, an SVG posing as a PNG and an image too large are
refused before decoding, an animated GIF becomes a gifv and a still one an image, HEIC isn't advertised. The media
scenarios against the pasture (GoToSocial, Mastodon, Misskey, Akkoma, Pixelfed, Smithereen, Vernissage, Castopod,
PeerTube) pass: 329 checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
An upload's owner checked that it was free, then attached it in a second step, so two posts asking for the same
upload at once could both get it; a post now, or an edit, could take media a scheduled post held, which then failed
when its time came (only logged) and was deleted; and a persona deleted or banned after scheduling a post still
published it.
Now a post claims its media in one conditional update before anything is written (its id is minted first), and a
post that loses the race is refused with 422, the media it took put back. Scheduling reserves media the same way.
Media held by a scheduled post are that post's alone, and its job publishes only for a persona still there whose root
is neither deleted nor banned; otherwise what it held is trashed. MastodonScheduledStatusesTests: a post now and an
edit can't take scheduled media, two racing posts never both get an upload, a gone persona's scheduled post never
publishes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Deleting a post, editing media out, replacing an avatar or a header, and removing a whole root deleted no file: every
one stayed on disk and publicly served from /media/files with a year-long immutable cache, its row orphaned. Now every
upload is a row, profile pictures too (Kind avatar or header, ProfileOfAvatarId), and each of those acts trashes what
it held, as does an upload never posted for a day and a dropped scheduled post. A trashed row is marked in one
conditional update (an upload attached meanwhile is left alone), its files move into media-trash, beside the media
root and outside what /media/files serves, and the janitor deletes them a day later. Nothing is deleted for looking
unused.
Along the way: a profile picture that isn't an image, or can't be read, answers 422 instead of being silently ignored
with a 200; a removed root's scheduled posts are dropped, so nothing of it publishes later; media rows get indexes
(they had none), and the janitor's first pass comes five minutes after boot instead of an hour.
`PrivaPub admin media audit [--fix]` compares the disk with the database. With --fix (as www-data) it gives the
pictures personas show today a row, and trashes media of deleted posts or personas, rows whose files are missing, and
files nothing holds: the leftovers of every deletion until now. MediaLifecycleTests covers each act, that the trash is
never served, and the audit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
float.TryParse takes "NaN" and "Infinity", and Math.Clamp keeps a NaN, so `focus=NaN,NaN` on an upload or a media PUT
was stored as given. Once the media was posted, every status and timeline response holding the post, and the Note
and its delivery, failed to serialise, for every viewer. FocalPoint.Parse takes finite numbers only (anything else is
ignored, as an unreadable focus was); the mapper and the renderer leave out a focus that isn't sound, and migration
_016 removes the ones stored before, from the uploads and from the copy each post keeps. What PrivaPub sends is
unchanged for any focus that could be sent before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A lease lasted two minutes and was never renewed, so the reaper gave any longer job to a second worker while the first
still ran it, and both finished it. The worker now renews the lease every third of its length while the handler runs;
a lease found taken (reaped and leased again) cancels the handler. Each lease carries its own owner stamp, since every
worker of a process shares one name, and Finish only counts for the lease it was given. Media processing and persona
archives will run longer than two minutes. JobQueueTests: a job three times its lease runs once with the reaper finding
nothing, and a stolen lease stops its handler and drops its outcome.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The pre-deploy mongodump kept InteractionSalt, the day's salt that the owner decided must be destroyed at each rollup:
a dump kept seven deploys long let the day's actor hashes be reversed. It is excluded now, and the deploy refuses a
dump that still names it (mongorestore's dry run lists every collection). The dumps already on the box still hold
old salts; removing them waits for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The fan-out put every post in its author's home, located ones too, while the Mastodon API looks past located posts
everywhere else. In the author's home a located post showed as public (the mapper has no word for it) and every action
on it answered 404: decePub's end-to-end runs, which write one from Milano for the globe, found it there. The fan-out
now gives a located post no home, no stream and no notification, and the home timeline passes over the entries left
from before. A located post is read through /clientapi/post/nearby and deleted through /clientapi/post/delete.
NearbyTests checks it has no TimelineEntry; the suite passes (881).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
ServiceReportTests looked at every delivery job of the last five minutes and read each body's actor. On the CI runner,
slower than the workstation, other tests' jobs were still in that window, one of them without an actor, and four cases
failed with a NullReferenceException. A test now sees only the jobs queued since it began, and checks the signer from
the job itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Both speak Lemmy's shapes and joined the servers that get reports in that shape only once the pasture showed each keeps
one with its reason. By their source, PieFed dropped the instance actor's report (it makes a user only of a Person or a
Service) and Mbin kept it without the persona's words (it reads the reason from `summary` alone). With them in
`ServiceReportTakers`, each keeps the reports of a thread and of a comment from "Reports from privapub.test" with
alice's words, and none names her (piefed.sh and mbin.sh, 65 checks with the full sweep's 876). A report of a PieFed
account alone, which PieFed would take from the reporter, is not sent yet; INTEROP says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it
answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object)
400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from
`privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own
audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the
community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06,
`ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not
reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left.
The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions
it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the
reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69
checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's
post too late once, and Ghost passes alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The crawler visits one server a minute, every one PrivaPub knows, and as the pasture grew mastodon.test's turn came
after the scenario stopped waiting; the scenario now makes it due at once. The backlog is regenerated after a sweep of
every peer: 2581 checks pass, none fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A full sweep found three peers silent. WordPress had updated itself to 7.1.2 from WP-Cron, and the new CA bundle dropped
Caddy's root: it now runs the 7.1.2 image with automatic updates off. Mbin's messenger worker had died when the shared
Postgres restarted under it: it now consumes again whenever it stops. Friendica sat in a quarantine left by the old
breaker. The threads scenario checks that PrivaPub never sends carol's reply to Mastodon, since a relay Mastodon has just
left (the relay scenario's) may still pass it on. With these, every scenario passes but the crawler's, which needs it
switched on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PrivaPub remembers a deleted remote post's id for 90 days so that a late Create cannot bring it back. Gancio numbers a
new event after the last one it keeps, so deleting its last event gives the next the same id, and that event was
dropped as deleted. A Create published after the deletion is now kept as the new post; the deleted one's own Create,
however late, still is not. Checked live: Gancio's scenario passes again (17 checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Lemmy keeps its user's post in a community pending until the community announces it back. A community hosted here
announced only to its followers, so a post from a server where nobody follows the community stayed pending there; the
author's own server (its shared inbox) now gets the announces too. INTEROP records, confirmed live, why Lemmy refuses
our Flags (an Application reporter, no `to`, an array object). FEDERATION.md's custom emoji line is put back before the
replies paragraph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
PeerTube puts canReply on a video whose comments wait for approval, and answers each comment with ApproveReply. A
persona's reply to such a post now waits (privapub.approval: pending), its Create going to the author alone; the
author's ApproveReply, signed by the author and naming the post answered, lets it out to its audience with
replyApproval, and RejectReply leaves it ours. A null canReply (PeerTube's open comments) says nothing; an empty one
refuses. The PeerTube scenario holds a comment for review and approves it through PeerTube's API (28 checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mastodon 4.3's policy was a stub that accepted everything. It is now kept per persona: notifications from accounts it
does not follow, accounts that do not follow it (or only for three days), accounts newer than 30 days, private mentions
it did not ask for and silenced accounts are accepted, filtered or dropped. Filtered ones stay out of every list and
count unless asked for, gathered in a request per account; letting a request in lets that account in for good,
dismissing it deletes them. Everything is accepted until the persona chooses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Ktistec 3.13.0 (peers/ktistec.sh): built from its tag (images/ktistec, its own Dockerfile pinned), set up through its
API. scenarios/ktistec.sh, 27 checks, twice in a row: follows, posts, replies, likes and boosts with their undos both
ways, its poll and alice's vote, FEP-044f quotes both ways, edits and deletions both ways, the unfollow, statistics. It
is driven through the part of the Mastodon API it speaks and its own outbox API, and read from a copy of its SQLite
database, since its API counts no likes or boosts. CLAUDE.md also asks that a change to what PrivaPub sends be run
against every peer before it is committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The proof term added with integrity proofs had a @graph container, which only JSON-LD 1.1 knows: Smithereen's reader
refused every document carrying our context with a 500. Those 500s, retried, then put Smithereen's host in quarantine
for an hour, and every delivery to it waited. Proofs are canonicalised as JSON (JCS), so the term loses nothing as a
plain id; a test keeps every term within 1.0. Only an unreachable host, a timeout or a gateway's 502, 503 or 504 now
counts against a host: a 500 is the server failing on that one activity, retried on its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mastodon's relationships API keeps each pair a day, and a read racing the change awaited writes the old answer back
after the change cleared it: an unfollow that had landed showed as a follow until the block. Changes from PrivaPub are
now awaited in Mastodon's database. The post to quote was found by its words, which alice's quotes from earlier runs
hold too; it is found by its id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Pleroma and Akkoma send a post's earlier versions with it. A post PrivaPub first meets after its edits now has its
history, and an edit that carries them brings the versions missed in between; each is read as the post itself is. The
Akkoma scenario checks a post fetched after two edits by an account no persona follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
An account on a server whose handles are not its host's (Mastodon's LOCAL_DOMAIN apart from WEB_DOMAIN) showed as
user@host. The actor's `webfinger` names the handle; its domain is kept once WebFinger there points back to the actor,
and asked again when the name changes. A persona's blocked servers match a handle's domain as well as the actor's host,
as the lists Mastodon exports name handles' domains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A draft-cavage hs2019 signature leaves the hash to the key: with an RSA key it is tried with SHA-256, as nearly everyone
signs, then with SHA-512, as some do; rsa-sha256 stays SHA-256 only. A Digest of SHA-512= is checked as SHA-256= is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Castopod 1.15.5 (peers/castopod.sh): the official image on the shared MySQL, its podcast @pod made and published through
its admin pages, episodes through its REST API, its fediverse queue sent by spark. scenarios/castopod.sh, 15 checks: the
follow, an episode reaching alice with its sound, her like, boost and comment landing there, the unfollow, statistics
(from NodeInfo2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Castopod announces an episode with a Note that only links to the episode's page, which serves a PodcastEpisode. PrivaPub
reads PodcastEpisode (its words in description, its sound in audio, its cover in image), and a Note that is only a link
to its author's own episode takes the episode's sound, title, cover and words, so the post plays. A server that
publishes no NodeInfo is described from NodeInfo2 (/.well-known/x-nodeinfo2), as Castopod publishes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Forte 26.9.10, the ActivityPub-only fork of Streams, built from its tag (images/forte: composer on Hubzilla's PHP image)
with portable identities (did:key actors behind /.well-known/apgateway). scenarios/forte.sh, 21 checks: follows, posts,
comments, likes, edits and deletions both ways, a third channel's comment passed on by the thread's owner, the
unfollow, statistics. INTEROP.md records what it does its own way: follows of profile pages, edits only as
Add{Update} under the Create's id, collections that misname themselves.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Forte follows a persona by its profile page (/@name, WebFinger's alias) rather than its actor's id: Follow and
Undo{Follow} now find the persona by either (it was a 404). Forte also sends an edit only added to the thread's context
(FEP-171b), under the same activity id as the post's Create: the unwrapping now tells two activities that share an id
apart by what they carry, as the inbox does, so the Update is not taken for a copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
FEP-171b, received (Hubzilla, Streams, Forte): an Add whose target is a collection of the actor's own server, and whose
object is someone's Create, Update or Delete of their own object, is queued as that activity forwarded by the owner, so
it is believed on its FEP-8b32 proof or as its origin has it, and shares its job with the plain forward Hubzilla also
sends. Checked live against Hubzilla (unchanged, 20 checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Hubzilla 11.4.1 with its pubcrawl addon (peers/hubzilla.sh): the hlhd image on the shared MySQL, a cron sidecar, hzuser
and hzfriend made through Hubzilla's own PHP as public channels that speak ActivityPub. scenarios/hubzilla.sh, 20
checks: follows, posts, comments, likes, edits and deletions both ways, and a third channel's comment that the thread's
owner passes on, which PrivaPub takes on its FEP-8b32 proof. Known gap G-0010 (upstream): Hubzilla 11 keeps a follower
after its Undo{Follow}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
FEP-521a and FEP-8b32. Every persona has an Ed25519 key of its own (Avatar.SigningKey; migration 014 gives the earlier
ones theirs), named in its actor's assertionMethod as a Multikey, the terms defined in the actor's own context. A
persona's activity going to a relay carries an eddsa-jcs-2022 proof (JSON canonicalised by RFC 8785, Jcs), so what
Activity-Relay forwards reaches Mastodon, which verifies it with its own code. Nothing else carries one: Mitra takes a
proof over the HTTP signature and refuses one by a key it has not read, without reading the actor again. Received: an
actor's own Multikeys are kept, and a forwarded activity whose proof one of them verifies is taken as it came instead of
being read again from its origin.
Discovery: WebFinger for the server's origin links its instance actor (FEP-d556), NodeInfo links it as the application
actor (FEP-2677), and actors name RFC 9421 under implements (FEP-844e).
Checked live: relay 16 (Activity-Relay's forward of alice's post reaches Mastodon), Mitra, GoToSocial and Mastodon
unchanged (165 in all).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.
Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.
Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
FEP-8fcf, received. When a delivery's Collection-Synchronization header digests the sender's followers on PrivaPub
otherwise than the personas following it, a job reads the list the header names (on the sender's origin, signed by the
instance actor): a follow the list leaves out ends, only when the list is the one the digest describes; a request it
lists is taken as accepted; a persona it lists that follows nothing there sends Undo{Follow}, as Mastodon does. Each
claiming delivery is compared once.
Checked live (scenarios/followsync.sh, now 15 checks): PrivaPub ends a follow Mastodon lost and undoes one only
Mastodon remembered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Owner decision of 2026-10-06 (FEP-8fcf). A persona's delivery addressed to its followers carries a signed
Collection-Synchronization header naming its followers, its roll-call (…/groupies/roll-call) and the digest of its
accepted followers on the receiving server only. The roll-call answers a signed request with the persona's followers
on the signer's server and nobody else's.
Mastodon gives every Undo{Follow} it sends after reading a roll-call the same id (…#follows//undo), so a second one
looked like a copy: an Undo of a Follow that comes again while the follow it ends exists again is now kept once per
follow.
Checked live (scenarios/followsync.sh): Mastodon drops a follow PrivaPub lost, and undoes one it lost itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Owner decision of 2026-10-06: a persona's public or unlisted post outside any group names its replies
(…/scribbles/{id}/replies) and its conversation's context (FEP-7888), the root's …/context that a reply inherits from
its parent, ours or another server's. Both list only the public and unlisted posts PrivaPub holds; followers-only,
circle, direct and local-only posts name neither and their collections answer 404. Checked live: opening alice's
thread on Mastodon finds carol's reply, which nobody there follows (scenarios/threads.sh).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
The owner decided four gated questions, now in ROADMAP: personas publish a wall (G-0009), their public posts go to the
relays PrivaPub subscribes to, public threads publish their replies and context, and FEP-8fcf follower digests are sent.
The first is in: a persona's own public post outside any group, its edit and its deletion also go to the relays that
accepted us, as Mastodon sends them; nothing less public, and no boost. Checked live (scenarios/relay.sh, 15 checks):
the post reaches Activity-Relay, and Mastodon through aode-relay's announce. Mastodon drops what Activity-Relay forwards
without an LD signature or FEP-8b32 proof, which PrivaPub does not add yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Once a follow holds, OutboxBackfill reads the account's latest public posts from its outbox's first page (twenty at
most, once a day) and keeps them as any fetched post: its profile shows them at once instead of only what it posts from
then on. Homes still get only what arrives afterwards, as on Mastodon. Announces and other servers' objects in the
outbox are left out. Checked live against Mastodon (scenarios/pins.sh, now 9 checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Vernissage joins the pasture on SQLite with its queues on the shared Redis; scenarios/vernissage.sh: follows both ways,
a photo with its alt text, likes, boosts and comments both ways, a deletion, the unfollow and statistics: 19 checks,
with no change to PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
An activity now goes once to each server, to its shared inbox when it has one, for the accounts a post names, answers
or quotes as for its followers, as Mastodon delivers (a circle post excepted: each member's copy names that member).
BookWyrm took the same post twice when one copy reached its shared inbox and another the named account's inbox at once.
SharedInboxTests checks a reply to a follower's post goes once.
BookWyrm joins the pasture (peers/bookwyrm.sh: its image on the shared Postgres and Redis, gunicorn and a Celery worker,
its user, book and statuses made in its Django shell). scenarios/bookwyrm.sh: follows both ways, a review, a comment and
a quotation reaching alice as BookWyrm's pure posts, her like, boost and reply landing there, her post naming bwuser and
bwuser's like and reply, a deletion, the unfollow and statistics: 20 checks. GoToSocial (64), Mastodon (57) and Misskey
(35) still pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
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
tools/pasture/flood/flood.cs answers as twenty fake servers (flood1..20.test)
and sends signed Creates, Likes and Follows at a set rate; load.sh measures
the answers, the queue's wait and processing times, its drain and a persona's
home timeline meanwhile (docs/LOAD.md has the method and the runs).
What the runs found:
- Every unique index was partial on $type: "string", which MongoDB never uses
for an equality lookup, so every post by ObjectURI, actor by ActorURI,
deleted object, domain block, remote instance and the rest was a
collection scan (280 ms a post lookup at 30 000 posts). They are partial on
$gt: "" now, which an equality on a string implies; MongoDB.Entities
rebuilds them in place at the next start.
- Two inbox workers capped intake near 110 activities a second:
Federation:InboxConcurrency and DeliveryConcurrency (default 8) set them.
- The indexes the plan listed as missing: a post's boosts and replies, a
persona's boosts, who follows an actor, timeline rows by author, a post's
likes and pins.
At 300 activities a second (200 let through, the rest 429 by the per-origin
limit) the queue wait went from 29 s to 6 ms at p50; with the limits lifted
PrivaPub processes about 900 a second, each in under 10 ms, and the home
timeline stays under 20 ms.
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
/clientapi/group had no way to see who is in a group or asks to join it,
so a circle's owner could not answer a request from elsewhere. Now:
- GET /clientapi/group/members: the members (local and remote, with their
role) and the pending requests, for the group's owner and moderators
only;
- POST /clientapi/group/reject: declines a request, telling the asker's
server with a Reject of its Follow;
- POST /clientapi/group/remove: takes a member out (never the owner): a
persona here stops following the group, one elsewhere gets a Reject of
its Follow, as Mastodon removes a follower.
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
Three shapes Funkwhale 2.0 sends, each of which lost something:
- its Accept is named after the Follow it answers, on our origin (`…#follows/<uuid>/accept`), and was refused as off its
actor's origin, so no follow of a channel completed: an Accept or Reject whose id extends the id of the activity it
answers, on our origin, is now taken;
- a channel deletes its uploads in one Delete without an id, their ids in a list as the object's id: each is deleted;
- its NodeInfo discovery names the document under its swagger schema's URL: a link whose path names NodeInfo is taken
when no rel is known.
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
Mobilizon's organiser sends the Create, Update and Delete of an event attributed to the group, which announces the
Event itself. PrivaPub refused the organiser's activities as misattributed (400) and kept the event through the
group's Announce, so an edit was lost and a deletion left the event in place. An object attributed to another account
of the actor's own server is now that server's to vouch for: created or edited as the server has it, under the account
it is attributed to, and deleted once the server answers 404 or 410. Attributed to an account elsewhere, it is still
refused. Checked against Mobilizon 5.2.4 in the pasture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Mobilizon sends its NodeInfo as `application/json; profile=http://…#` with the URL unquoted, which .NET cannot parse,
so the document was refused for its content type and the server never described; and it names its software
"Mobilizon" where NodeInfo wants lower case. The media type is now read from the raw header when the parsed one is
missing, and software names are lowercased, so one software is counted under one name.
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
Friendica's activity ids are uniqid(): a short prefix and the microsecond. Two of its processes answering two follows at
once gave both Accepts one id, and PrivaPub, queueing each inbox activity once per id, dropped the second as a copy:
that follow stayed pending on our side while Friendica counted the persona as a follower (seen in the town's Friendica
pair). An id that comes back carrying another type, actor or object is now queued apart; a true copy is still dropped.
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
A Follow was resent only when the persona followed again. Lemmy 1.0
sends nothing it queued for a server before it started sending there, so
the Accept of a community follow made on first contact was lost for good:
the village's persona stayed "requested" a day while Lemmy listed her as a
follower, and every post the community announced was refused as not
followed. A request still unanswered is now sent again, the same activity,
after 15 minutes, an hour, 6 hours, a day, two and four days
(FollowResender, every 15 minutes); a server that holds the follow answers
the copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Lemmy sends a vote to the community alone, which relays it to the post's
server. A persona's reply in a thread of a community nobody here follows
lost its Lemmy upvotes: the relay was dropped as "not followed". Such a
relay is now taken when the vote (or its undo) is on one of our posts in a
thread rooted in that community; from any other unfollowed group it is
still dropped. For that, a post keeps the community its `audience` names
(FEP-1b12) however it arrived, fetched for a thread as well as announced.
Found by the town's village (p191: 1 like counted of 2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
A delivery signed with RFC 9421 was counted under "other": its algorithm
(rsa-v1_5-sha256) has an underscore, which a signature name could not
hold. It is now counted as rfc9421:rsa-v1_5-sha256, as WordPress's are.
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
Pixelfed names one of our posts by the address of its page
(/@name/<post id>, the post's url) in its Like, Announce and their Undo,
so its likes were dropped as unknown objects. Before an activity is
handled, such a reference to a post of ours, as its object or the object
of the activity it undoes, is replaced by the post's id. Found by the
pasture's new Pixelfed peer.
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
Since status search by words, searching "@name@host" also answered posts
sharing the handle's words and a hashtag "#@name@host" made of the query,
so a client that opens the profile when the account is the only result
(decePub's search) stayed on the list. A handle now searches accounts only,
and a hashtag result is offered only when the query is one (letters, marks,
digits and underscores, with a letter).
Found by decePub's end-to-end tests: every profile follow test failed.
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 moderators' reports and the admin statistics (overview, hosts,
crawler) were anonymous objects, so a client could read them only as loose
JSON. They are now ViewReport, ViewStatisticsOverview, ViewHostsPage and
ViewCrawler in PrivaPub.ClientModels, with the same JSON as before, for
decePub's Administration page.
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
Lemmy turns an upvote into a downvote with a Dislike alone (and back with a
Like), so the like stayed counted next to the downvote. Now an account has
one vote on a post: a Dislike takes its like away, a Like its downvote.
The Lemmy scenario's relayed votes were never failing for that reason only:
Lemmy 1.0 sends what it queued every 30 seconds, and the check gave up after
30. It waits a minute now, and both votes are plain checks: 22 pass, the
moderator's removal stays an expected failure (P7).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Saving the notifications marker (/api/v1/markers) moved the marker but left
every notification unread, so a Mastodon client's unread count never fell.
The marker now marks read every notification up to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
v1 notifications, and those a stream tells, carried an 'ungrouped' key, so
a client reading grouped notifications could not fold a new like into the
group of likes it already shows. Each now names the group it belongs to by
default (likes and boosts of one post, follows within an hour).
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
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
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
Two ledger flushes that upsert the same new day (or touch the same new
instance) at once made one of them fail on the unique key, losing its
counts; MongoDB retries that only for single-document updates. The loser
now tries again and adds to the document the winner made. Seen as a rare
failure of LedgerOverHttpTests in full runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
Many personas share a published day, and a page cut through such a day could
take a different few of them each time, skipping some between two pages: the
cut now takes the whole day at its edge. The test reads the directory page by
page until it finds its persona and checks each page's order, since other
tests make personas between two pages.
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
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
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
A boost and its undo went to the author's personal inbox and to the shared
inbox of its server's followers: two copies at once, which Misskey counted
twice as it processed them and undid once (seen in decePub's e2e boost on
Misskey). The author's server now gets them through its shared inbox, as
Mastodon sends them; a like still goes to the author's own inbox.
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