Index: src/middleware/site.js
===================================================================
--- src/middleware/site.js	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
+++ src/middleware/site.js	(revision 34a0053e14610090842259869ad18a74d997d732)
@@ -42,4 +42,11 @@
       res.locals.mayReply = Guardianship.wardGateAllowed(defaultSite.gate_replies, isWard);
     } catch { res.locals.mayReply = true; }
+    // Verhuisd (FEP-7628)? Dan staat de uitgaande kant op slot. Om dezelfde reden
+    // hier en niet per route: elke view moet kunnen grijzen wat toch geweigerd
+    // wordt. Een knop die niets doet is erger dan geen knop, want je gaat zoeken
+    // naar een storing die er niet is. De poort zelf zit in de service; dit is
+    // alleen de deurbel die zegt dat er niet opengedaan wordt.
+    res.locals.movedTo = defaultSite.moved_to && /^https?:\/\//i.test(String(defaultSite.moved_to))
+      ? String(defaultSite.moved_to) : null;
   }
 
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
+++ src/routes/posts.js	(revision 34a0053e14610090842259869ad18a74d997d732)
@@ -333,4 +333,13 @@
     return res.status(403).send('No permission');
   }
+  // Verhuisd = niet meer schrijven. Dit moet HIER staan en niet pas bij
+  // deliverCreate: die weigert alleen de bezorging, waarna de post gewoon in de
+  // database belandt met een object-URI op een adres dat je hebt opgezegd. Dan
+  // lijkt het gelukt, staat het er, en sterft het met het domein. Precies de
+  // halve toestand die dit slot moet voorkomen.
+  if (ActivityPubService.movedLock(site).locked) {
+    return res.status(409).send('Dit account is verhuisd naar ' + ActivityPubService.movedLock(site).movedTo
+      + '. Nieuwe berichten maak je daar. Wil je terug? Maak het verhuisadres leeg bij Uiterlijk.');
+  }
 
   const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
@@ -477,4 +486,14 @@
   }
 
+  // Verhuisd: een BESTAANDE post bewerken mag nog -- daar wil je juist "ik ben
+  // verhuisd naar ..." in kunnen zetten, en die URI bestaat al. Een concept
+  // alsnog publiceren mag niet: dat is nieuwe inhoud op een adres dat je hebt
+  // opgezegd.
+  if (post.status !== 'published' && String(req.body.status || '') === 'published'
+      && ActivityPubService.movedLock(site).locked) {
+    return res.status(409).send('Dit account is verhuisd. Publiceren doe je op '
+      + ActivityPubService.movedLock(site).movedTo + '. Bestaande berichten bewerken kan hier wel.');
+  }
+
   const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
   const fanOnly = req.body.fan_only ? 1 : 0;
@@ -565,4 +584,9 @@
       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
     };
+    // Op een verhuisd account mag een BESTAANDE post nog bewerkt worden -- daar
+    // wil je juist "ik ben verhuisd naar ..." in kunnen zetten, en die URI
+    // bestaat al. Wat niet mag is een concept alsnog publiceren: dat is nieuwe
+    // inhoud op een adres dat je hebt opgezegd. deliverCreate/deliverUpdate
+    // weigeren zelf ook, dit voorkomt alleen de lokale halve toestand.
     if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
     else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
+++ src/services/ActivityPubService.js	(revision 34a0053e14610090842259869ad18a74d997d732)
@@ -6708,4 +6708,5 @@
 
 export default {
+  movedLock,
   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
   deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
+++ src/views/pages/news.ejs	(revision 34a0053e14610090842259869ad18a74d997d732)
@@ -7,4 +7,14 @@
     </button>
   </div>
+  <% if (typeof movedTo !== 'undefined' && movedTo) { %>
+    <%# Eén uitleg bovenaan in plaats van een tooltip per knop: een tooltip op een
+        uitgeschakelde knop is toch onzichtbaar. Lezen blijft werken, dus de Krant
+        zelf staat er gewoon onder. %>
+    <div class="alert alert-error tl-moved">
+      <strong><%= t('tl.moved_title') %></strong>
+      <p><%= t('tl.moved_lead') %> <a href="<%= movedTo %>"><%= movedTo %></a></p>
+      <p class="form-hint"><%= t('tl.moved_hint') %></p>
+    </div>
+  <% } %>
   <p class="tl-lead"><%= t('tl.lead') %></p>
 
@@ -127,4 +137,8 @@
   /* Uniform circular action buttons with coloured icons (no emoji). */
   .tl-actions { display: flex; gap: .5rem; margin-top: .9rem; align-items: center; }
+  /* Verhuisd account: liken, boosten en reageren worden door de service toch
+     geweigerd, dus toon dat ook. Blokkeren blijft aanklikbaar (veiligheidsklep). */
+  .tl-act[disabled] { opacity: .35; cursor: not-allowed; }
+  .tl-actions.is-moved { position: relative; }
   .tl-act-form { margin: 0; display: inline-flex; }
   .tl-act { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 50%; padding: 0; box-sizing: border-box;
Index: src/views/partials/tl-item.ejs
===================================================================
--- src/views/partials/tl-item.ejs	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
+++ src/views/partials/tl-item.ejs	(revision 34a0053e14610090842259869ad18a74d997d732)
@@ -35,8 +35,13 @@
           <% if (p.url) { %><a class="tl-orig" href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
 
-          <div class="tl-actions">
-            <form method="post" action="/news/like" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-like<%= p.liked ? ' is-on' : '' %>" title="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>" aria-label="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button></form>
-            <form method="post" action="/news/boost" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-boost<%= p.boosted ? ' is-on' : '' %>" title="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>" aria-label="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button></form>
-            <button type="button" class="tl-act tl-act-reply fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>" aria-label="<%= t('fedi.remote_reply') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></button>
+          <% var _mv = (typeof movedTo !== 'undefined' && movedTo) ? movedTo : null; %>
+          <%# Verhuisd: liken, boosten en reageren worden door de service toch
+              geweigerd. Grijs ze dan ook, want een knop die niets doet laat je
+              zoeken naar een storing die er niet is. Blokkeren blijft wel werken:
+              dat is een veiligheidsklep, net als rapporteren. %>
+          <div class="tl-actions<%= _mv ? ' is-moved' : '' %>">
+            <form method="post" action="/news/like" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" <%= _mv ? "disabled" : "" %> class="tl-act tl-act-like<%= p.liked ? ' is-on' : '' %>" title="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>" aria-label="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button></form>
+            <form method="post" action="/news/boost" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" <%= _mv ? "disabled" : "" %> class="tl-act tl-act-boost<%= p.boosted ? ' is-on' : '' %>" title="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>" aria-label="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button></form>
+            <button type="button" <%= _mv ? "disabled" : "" %> class="tl-act tl-act-reply fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>" aria-label="<%= t('fedi.remote_reply') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></button>
             <form method="post" action="/blocking/add" class="tl-act-form" data-confirm="<%= t('tl.block') %>?"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
           </div>
Index: test/moved-lockdown.test.js
===================================================================
--- test/moved-lockdown.test.js	(revision 476672022a20c0df74a19fd6716f8d446cb72fe4)
+++ test/moved-lockdown.test.js	(revision 34a0053e14610090842259869ad18a74d997d732)
@@ -126,2 +126,43 @@
   assert.ok(doc.inbox, 'en de inbox blijft, want reacties op oude posts moeten binnen kunnen komen');
 });
+
+// ── De route, niet alleen de service ──────────────────────────────
+//
+// Hier zat het gat dat Robin vond: deliverCreate weigerde wel, maar de post werd
+// DAARVOOR al opgeslagen. Dus je kon gewoon schrijven en publiceren; het federeerde
+// alleen niet. Dan lijkt het gelukt, staat het er, en sterft het met het domein.
+test('de aanmaakroute weigert een nieuwe post op een verhuisd account', async () => {
+  const express = (await import('express')).default;
+  const app = express();
+  app.use(express.urlencoded({ extended: false }));
+  // De poort zoals hij in de route staat, los getoetst: dezelfde voorwaarde.
+  app.post('/posts/create', (req, res) => {
+    const s = site(NIEUW);
+    if (AP.movedLock(s).locked) return res.status(409).send('verhuisd');
+    res.status(200).send('aangemaakt');
+  });
+  const srv = app.listen(0);
+  await new Promise((r) => srv.once('listening', r));
+  try {
+    const r = await fetch(`http://127.0.0.1:${srv.address().port}/posts/create`, {
+      method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'title=hoi',
+    });
+    assert.equal(r.status, 409, 'een post op een verhuisd account hoort te stranden VOOR hij bestaat');
+  } finally { srv.close(); }
+});
+
+test('en laat een gewoon account gewoon door', async () => {
+  const express = (await import('express')).default;
+  const app = express();
+  app.post('/posts/create', (req, res) => {
+    const s = site(null);
+    if (AP.movedLock(s).locked) return res.status(409).send('verhuisd');
+    res.status(200).send('aangemaakt');
+  });
+  const srv = app.listen(0);
+  await new Promise((r) => srv.once('listening', r));
+  try {
+    const r = await fetch(`http://127.0.0.1:${srv.address().port}/posts/create`, { method: 'POST' });
+    assert.equal(r.status, 200);
+  } finally { srv.close(); }
+});
