Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision cae8ded17a1ccf1df4860076a16aa2d9394881f7)
+++ src/services/ActivityPubService.js	(revision 5462bab0a30abfb9338812cb154a11cebda17b67)
@@ -46,4 +46,7 @@
     manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
     discoverable: 'toot:discoverable',
+    // FEP-7628 (account moves): same term declaration Mastodon ships.
+    alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
+    movedTo: { '@id': 'as:movedTo', '@type': '@id' },
     featured: { '@id': 'toot:featured', '@type': '@id' },
     PropertyValue: 'schema:PropertyValue',
@@ -221,4 +224,14 @@
   // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
   if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
+  // FEP-7628: former identities this account claims. The OLD server checks for
+  // exactly this back-reference before it will move followers here, so the
+  // list must be on the public actor, not tucked away in settings.
+  try {
+    const aka = JSON.parse(site.ap_aliases || '[]');
+    if (Array.isArray(aka)) {
+      const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id);
+      if (clean.length) actor.alsoKnownAs = clean;
+    }
+  } catch { /* skip malformed ap_aliases */ }
   // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
   // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
@@ -1437,5 +1450,5 @@
   // Blocked actor/domain → silently drop (202, don't reveal the block).
   if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
-  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
+  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
   if (GATED.includes(type)) {
     if (!verified || !claimedActor || verified.id !== claimedActor) {
@@ -1513,4 +1526,11 @@
     } catch { /* ignore */ }
     return 202;
+  }
+
+  // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
+  // a Move often arrives seconds before the new actor's re-Follow wave, and the
+  // swap below must not race our own outgoing Follow of the target.
+  if (type === 'Move') {
+    return handleMoveInbox(act, { verifiedActor: claimedActor });
   }
 
@@ -3760,4 +3780,67 @@
 }
 
+/**
+ * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can
+ * still change): an account our sites follow says it moved to a new home.
+ *
+ * Validity has two independent legs, and both must hold:
+ *  1. The SIGNER is a party to the move: the old actor announcing its own move
+ *     (push mode) or the new actor doing it (pull mode). A third party
+ *     narrating someone else's move is refused — without this, any signed
+ *     stranger could re-point our follows.
+ *  2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the
+ *     cross-side proof: the mover controls both ends. Without it, whoever
+ *     holds ONE end could hijack the other end's followers.
+ *
+ * Effect: every local site following the old actor unfollows it and follows
+ * the new one, keeping its auto-boost choice. Deliberately NOT retargeted:
+ * guardianship relations (FEP-633c) — a guardian is a security anchor, not a
+ * feed subscription, and moving one is shaer-tge's gated decision, not a
+ * side effect of an inbox event. We only log when a move touches one.
+ *
+ * Deps are injectable for tests (no network in node:test).
+ */
+export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) {
+  const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
+  const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id);
+  if (!oldUri || !newUri || oldUri === newUri) return 400;
+  if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) {
+    console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri);
+    return 401;
+  }
+  // Nobody here follows the old actor → nothing to move. This also makes
+  // redelivery idempotent: after the first swap the rows are gone.
+  let rows = [];
+  try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ }
+  if (!rows.length) return 202;
+  // A blocked destination is declined outright: the old follow stays (it goes
+  // stale on its own), and we will not open a door to a blocked house.
+  if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; }
+  const target = await (fetchActorFn || fetchActor)(newUri);
+  const aka = [].concat((target && target.alsoKnownAs) || [])
+    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
+  if (!target || !target.id || !aka.includes(oldUri)) {
+    console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri);
+    return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server
+  }
+  for (const row of rows) {
+    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug);
+    if (!site) continue;
+    try {
+      await (unfollowFn || unfollowActor)(site, oldUri);
+      const already = fwStmts().one.get(row.slug, newUri);
+      if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost);
+      console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri);
+    } catch (e) {
+      console.warn('[AP] move re-follow failed for', row.slug, e && e.message);
+    }
+  }
+  try {
+    const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted');
+    if (g.length) console.warn('[AP] Move touches a guardianship party — left untouched (shaer-tge):', oldUri, '→', g.map((r) => `${r.role}:${r.slug}`).join(', '));
+  } catch { /* table absent on fresh init */ }
+  return 202;
+}
+
 // FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
 /**
@@ -4276,5 +4359,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision cae8ded17a1ccf1df4860076a16aa2d9394881f7)
+++ src/services/i18n.js	(revision 5462bab0a30abfb9338812cb154a11cebda17b67)
@@ -252,4 +252,6 @@
     'asite.links': 'Social / streaming-links',
     'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.',
+    'asite.aliases': 'Fediverse-aliassen',
+    'asite.aliases_hint': 'Eén per regel: je oude account als @naam@server of als actor-URL. Nodig om volgers van een oud account hierheen te verhuizen; de oude server controleert of dit profiel het oude claimt.',
     'asite.link_add': '+ Link toevoegen',
     'asite.feed_view': 'Feed-weergave',
@@ -1188,4 +1190,6 @@
     'asite.links': 'Social / streaming links',
     'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.',
+    'asite.aliases': 'Fediverse aliases',
+    'asite.aliases_hint': 'One per line: your old account as @name@server or as an actor URL. Needed to move followers from an old account to this one; the old server checks that this profile claims the old one.',
     'asite.link_add': '+ Add link',
     'asite.feed_view': 'Feed display',
@@ -2123,4 +2127,6 @@
     'asite.links': 'Social- / Streaming-Links',
     'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.',
+    'asite.aliases': 'Fediverse-Aliasse',
+    'asite.aliases_hint': 'Einer pro Zeile: dein altes Konto als @name@server oder als Actor-URL. Nötig, um Follower eines alten Kontos hierher umzuziehen; der alte Server prüft, ob dieses Profil das alte beansprucht.',
     'asite.link_add': '+ Link hinzufügen',
     'asite.feed_view': 'Feed-Anzeige',
