Skip to content

Migrate actor IDs from permalink to query-param format - #2766

Draft
pfefferle wants to merge 2 commits into
fix/account-move-verificationfrom
add/move-profile-ids
Draft

Migrate actor IDs from permalink to query-param format#2766
pfefferle wants to merge 2 commits into
fix/account-move-verificationfrom
add/move-profile-ids

Conversation

@pfefferle

@pfefferle pfefferle commented Jan 14, 2026

Copy link
Copy Markdown
Member

Stacked on #3585 (the account-move fixes), which it depends on. Review and merge that one first; this PR's base is set to it so the diff shows only the migration.

Proposed changes:

A permalink-based actor id embeds the handle (/@handle for the blog, the author-archive URL for a user), so changing the handle changes the id and strands followers. This moves every actor that used a permalink id to the stable ?author=ID form, which does not contain the handle.

  • Drop the permalink branch from Blog::get_id() / User::get_id(), so ids are always the query-param form.
  • A migration federates a Move for every permalink-id actor, from the old id to the new one, and keeps the old id resolving with a movedTo.
  • The migration reuses the corrected Move::internally_by_actor() from Fix account move verification and notify followers #3585, so each move also records the target's alsoKnownAs and sends the follow-up profile Update.
  • A Move that re-identifies a local actor (a same-domain target) is broadcast to all known inboxes, not just followers, so every server that cached the old id updates. A Move to a remote account stays followers-only per FEP-7628.

The old id keeps resolving without the model ever inspecting the request: Query::is_permalink_actor_request() recognizes the old-URL request from its query vars (actor for /@handle, author_name for /author/handle, neither for ?author=ID), and a construct-hook handler serves a stored snapshot, exactly as the domain-move path already does.

Other information:

  • Have you written new tests for your changes, if applicable?

Testing instructions:

  • Enable a permalink id: set the activitypub_use_permalink_as_id_for_blog option (blog) or the activitypub_use_permalink_as_id user option (user) to 1.
  • Lower activitypub_db_version to force the migration (e.g. wp option update activitypub_db_version 9.1.0) and load the site.
  • The actor's id is now ?author=ID, the outbox has a Move from the old id, and fetching the old /@handle (or /author/handle) URL returns the old id with movedTo pointing at the new one.

Changelog entry

Included in the branch (.github/changelog/migrate-permalink-ids-to-query-param).

Changelog Entry Details

Significance

  • Minor

Type

  • Changed

Message

Use a stable Fediverse profile ID that no longer breaks your followers when you change your handle, and migrate existing profiles to it automatically.

@pfefferle
pfefferle force-pushed the add/move-profile-ids branch from 2bb8436 to bc5509d Compare July 28, 2026 11:07
@pfefferle
pfefferle changed the base branch from trunk to fix/account-move-verification July 28, 2026 11:07
@pfefferle pfefferle changed the title Add migration to move profile IDs from permalink to query param format Migrate actor IDs from permalink to query-param format Jul 28, 2026
@pfefferle

Copy link
Copy Markdown
Member Author

I checked how the biggest platforms really handle Move and actor id changes. I cloned the repos (default branches) and read the code, instead of trusting the docs. It seems we should not ship the automatic Move here.

The problem

The migration sends a Move where source and target are the same actor: same handle, same domain, only the id format changes. On Mastodon this looks like it loses followers, permanently.

What I read in Mastodon main (89c9fa1c9a):

  1. Mastodon finds remote accounts by uri, and falls back to (username, domain) when webfinger is verified (process_account_service.rb:53-55). Both columns are unique, so it can not create a second row.
  2. Fetching the new id rewrites the existing row's uri in place (:227), sets @uri_changed and queues RefollowWorker (:86, :310-312).
  3. move.rb:18 then sets moved_to_account_id to the id of the same row. There is no check against a self reference, so the account is now "moved" to itself.
  4. RefollowWorker first deletes the local follow row (refollow_worker.rb:19), then the re-follow is refused because the account is moved (follow_service.rb:57). The error is swallowed (:24-25) and the worker has retry: false, so the follower is gone for good.

The account also stays flagged as moved to itself. That blocks new follows and hides it from search.

Mastodon does this correctly without a Move

There is a path built exactly for our case:

# Allow accounts to change URIs if they keep the same handle
# (typically, losing database or switching ActivityPub server implementation)
@account ||= Account.find_remote(@username, @domain) if @webfinger_verified

It rewrites the uri and re-follows cleanly, because moved? is still false at that point. The Move is what breaks it.

Good news for that path: we already answer the re-issued Follow correctly. queue_accept does not check $success (class-follow.php:29), so an existing follower still gets an Accept. No change needed there.

But just dropping the Move does not fix everything

This is the part I like least. An id change under the same handle behaves differently everywhere:

Platform Users Without a Move With a Move
Mastodon (+ forks) 64.8% works breaks
Misskey / Sharkey 9.3% goes silent can not work either
Pixelfed 8.4% works works
Pleroma / Akkoma / Mitra 0.7% duplicate actor, followers stranded works

Misskey is the worst one. No ActivityPub path ever writes a new uri, and UNIQUE(usernameLower, host) makes a duplicate impossible, so it errors out and drops every incoming activity. It only recovers when a local user looks up or mentions the handle, and only if the row is older than 24h.

For Pleroma, Akkoma and Mitra it is the opposite: they create a second actor anyway, and the Move is what moves the followers over to it. Mitra even calls the stable actor id an invariant in its code (types.rs:712, "actor_id of remote actor: must not change").

So there is no way to change an actor id that is safe everywhere. We can only pick who pays.

Two more things I found while looking:

  • A profile Update can not announce an id change on Mastodon, in both directions. Signed by the old id it hits reject_payload! (update.rb:24), signed by the new id it is dropped with head 202 before the signature is even checked (inboxes_controller.rb:30-37).
  • If we ever do change ids, we have to change the key id too. Otherwise Keypair.from_keyid hits and Mastodon never refetches the actor. And we must never answer 410 on the old actor url, that triggers queue_deletion! and deletes the account.

What I am not sure about

Step 4 above depends on a race between the RefollowWorker job and move.rb:18, which run microseconds apart. The bad branch is much more likely, because Sidekiq needs way longer to pick up a job, but I can not prove it from the code. There is also no test for this upstream, move_spec.rb only uses different domains. So I would test this against a real Mastodon instance before we decide.

Suggestion

I think we should not send the Move for this migration, and maybe not do the migration at all for existing actors. We change ids now, with a real cost, to avoid stranding followers later when someone changes their handle. But the id change itself strands followers on about 10% of the network and makes the account go quiet on Misskey. That is possibly more expensive than the problem it prevents.

The smaller version would be to use the query param id only for new actors and leave existing ones alone, so nobody pays for a migration they did not ask for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant