Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision bb9e54cec02ebc6188104575f97e2d80a9b52679)
+++ src/routes/activitypub.js	(revision e311202e61b8a8e0ce2a0f0f23d873495f89d76f)
@@ -1258,4 +1258,13 @@
   // cannot_resolve_inReplyTo. Strangers still get the exact same 404, so a
   // note's existence stays as private as before.
+  // EEN GEBLOKKEERDE KRIJGT DE DEUR DICHT (Robin, 21-8), net als bij de outbox:
+  // wie ondertekend aanklopt, klopt met zijn naam erop, en een blokkade is een
+  // gesloten deur. Dezelfde 404 als een vreemde, zodat het bestaan van een note
+  // niets extra's verraadt. Onbetekende verzoeken kunnen we niet thuisbrengen
+  // en houden de publieke weergave -- daarvoor is de Block-bezorging.
+  if (req.headers['signature']) {
+    const wie = await AP.verifyRequest(req).catch(() => null);
+    if (wie && wie.id && AP.isBlockedAny(wie.id)) return res.status(404).end();
+  }
   const post = db.prepare(
     "SELECT * FROM posts WHERE id = ? AND status = 'published'"
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision bb9e54cec02ebc6188104575f97e2d80a9b52679)
+++ src/routes/posts.js	(revision e311202e61b8a8e0ce2a0f0f23d873495f89d76f)
@@ -1534,5 +1534,5 @@
 router.post('/blocking/remove', requireSiteManager, (req, res) => {
   const site = res.locals.site;
-  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
+  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()).catch(() => {}); } catch (e) { /* ignore */ } }
   res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
 });
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision bb9e54cec02ebc6188104575f97e2d80a9b52679)
+++ src/services/ActivityPubService.js	(revision e311202e61b8a8e0ce2a0f0f23d873495f89d76f)
@@ -3600,5 +3600,5 @@
         if (innerType === 'Block') {
           if (!innerTarget) return { status: 400, error: 'missing_object' };
-          unblock(site, innerTarget);   // release from Orbit
+          unblock(site, innerTarget).catch(() => {});   // release from Orbit
           return { status: 202, url: innerTarget };
         }
@@ -6666,7 +6666,24 @@
 // Block an actor (@handle or actor URL) or a whole domain; purges their content.
 // The handle resolver is ours; the storage/purge lives in BlocklistService.
-export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); }
-
-export function unblock(site, target) { return Blocklist.unblock(site, target); }
+//
+// De BEZORGING hoort ook hier: BlocklistService kent de database, niet het
+// afleveren. Een Block gaat naar de inbox van wie je blokkeert, een
+// Undo(Block) bij het opheffen -- zonder retry-wachtrij, want een blokkade
+// wacht niet op een server die even plat ligt (en bij opheffen komt de ander
+// vanzelf weer langs).
+async function bezorgBlokkade(site, target, undo) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const me = actorId(base, site.slug);
+  const blok = { id: `${me}#block-${Date.now()}-${rid()}`, type: 'Block', actor: me, object: target, to: [target] };
+  const activiteit = undo
+    ? { '@context': AP_CONTEXT, id: `${me}#unblock-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: blok, to: [target] }
+    : { '@context': AP_CONTEXT, ...blok };
+  const r = await deliverToActor(site, target, activiteit);
+  console.log('[AP]', undo ? 'Undo(Block)' : 'Block', site.slug, '→', target, r && r.delivered ? 'bezorgd' : 'niet bezorgd');
+}
+
+export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve, bezorgBlokkade); }
+
+export function unblock(site, target) { return Blocklist.unblock(site, target, bezorgBlokkade); }
 
 // ── Guardianship module wiring (src/services/guardianship/) ────────
Index: src/services/BlocklistService.js
===================================================================
--- src/services/BlocklistService.js	(revision bb9e54cec02ebc6188104575f97e2d80a9b52679)
+++ src/services/BlocklistService.js	(revision e311202e61b8a8e0ce2a0f0f23d873495f89d76f)
@@ -56,5 +56,24 @@
 // `resolveHandle` (async handle → actor URL) is injected by the caller so this
 // service needs nothing from ActivityPubService (no circular import).
-export async function blockTarget(site, input, resolveHandle) {
+/**
+ * Een blokkade is pas een blokkade als de ander het merkt (Robin, 21-8).
+ *
+ * Tot vandaag bleef hij binnenshuis: rij in ap_blocks, inhoud opruimen, volger
+ * eruit -- en verder niets. De andere kant volgde je dan nog steeds in zijn
+ * eigen boeken en bleef je publieke outbox lezen. Precies wat de hub deed:
+ * kanaal en berichten stonden er gewoon nog. Vandaar dat we het nu ook
+ * VERSTUREN, zoals Mastodon dat doet: een Block naar de inbox van wie je
+ * blokkeert, en bij opheffen een Undo(Block) zodat de weg terug openligt.
+ *
+ * Alleen voor een actor-blokkade: een heel domein heeft geen inbox om aan te
+ * schrijven. En bezorgen mag nooit de blokkade zelf tegenhouden -- die staat
+ * al vast in de database voordat we ook maar iets proberen te versturen.
+ */
+async function meldBlokkade(site, target, kind, bezorg, undo = false) {
+  if (kind !== 'actor' || typeof bezorg !== 'function') return;
+  try { await bezorg(site, target, undo); } catch { /* de blokkade staat, de melding is een gunst */ }
+}
+
+export async function blockTarget(site, input, resolveHandle, bezorg) {
   const raw = String(input || '').trim();
   if (!site || !site.slug || !raw) return { error: 'empty' };
@@ -69,8 +88,16 @@
   purgeBlocked(kind, target);
   console.log('[AP] block', site.slug, kind, target);
+  await meldBlokkade(site, target, kind, bezorg);
   return { ok: true, label };
 }
 
-export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
+export async function unblock(site, target, bezorg) {
+  const rij = blStmts().list.all(site.slug).find((b) => b.target === target);
+  blStmts().del.run(site.slug, target);
+  // Undo(Block) zodat de ander weet dat de deur weer open is; zonder dit blijft
+  // hij bij zichzelf geblokkeerd staan en komt hij nooit terug.
+  await meldBlokkade(site, target, (rij && rij.kind) || 'actor', bezorg, true);
+  return { ok: true };
+}
 
 export default { listBlocks, isBlockedAny, blockTarget, unblock };
Index: test/block-federeert.test.js
===================================================================
--- test/block-federeert.test.js	(revision e311202e61b8a8e0ce2a0f0f23d873495f89d76f)
+++ test/block-federeert.test.js	(revision e311202e61b8a8e0ce2a0f0f23d873495f89d76f)
@@ -0,0 +1,77 @@
+// Een blokkade wordt ook VERSTUURD (Robin, 21-8).
+//
+// Aanleiding: dev.klonkt.com blokkeerde de hub, en op de hub bleef het kanaal
+// met alle berichten gewoon staan. De blokkade bleef namelijk binnenshuis --
+// rij in ap_blocks, inhoud opruimen, volger eruit -- en de andere kant hoorde
+// er nooit van. Nu gaat er een Block naar de inbox van wie je blokkeert, en
+// bij opheffen een Undo(Block), zodat de weg terug openligt.
+//
+// De fetch is gestubd: het gaat om WAT er de deur uit gaat, niet om echte HTTP.
+import { test, after } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES ('u1','u1','u1@t','x','god')").run();
+db.prepare("INSERT INTO sites (id, slug, title, owner_id, is_public) VALUES ('s1','dev','Dev','u1',1)").run();
+const site = () => db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+
+// IP-literal: safeFetch slaat de DNS-lookup over en de stub vangt de rest.
+const DOEL = 'https://203.0.113.80/ap/actor';
+const bezorgd = [];
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  if (opts.method === 'POST') {
+    try { bezorgd.push({ naar: u, activiteit: JSON.parse(opts.body) }); } catch { /* niet-JSON telt niet mee */ }
+    return new Response('', { status: 202 });
+  }
+  return new Response(JSON.stringify({
+    id: DOEL, type: 'Application', preferredUsername: 'hub', inbox: `${DOEL}/inbox`,
+  }), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+};
+after(() => { globalThis.fetch = echteFetch; });
+
+test('blokkeren stuurt een Block naar de geblokkeerde', async () => {
+  bezorgd.length = 0;
+  const r = await AP.blockTarget(site(), DOEL);
+  assert.equal(r.ok, true);
+  assert.equal(db.prepare('SELECT count(*) c FROM ap_blocks WHERE target = ?').get(DOEL).c, 1, 'de blokkade staat vast');
+  const blok = bezorgd.find((b) => b.activiteit.type === 'Block');
+  assert.ok(blok, 'er is een Block bezorgd');
+  assert.equal(blok.naar, `${DOEL}/inbox`);
+  assert.equal(blok.activiteit.object, DOEL);
+  assert.equal(blok.activiteit.actor, 'https://klonkt.test/ap/users/dev');
+});
+
+test('opheffen stuurt een Undo(Block), anders komt de ander nooit terug', async () => {
+  bezorgd.length = 0;
+  await AP.unblock(site(), DOEL);
+  assert.equal(db.prepare('SELECT count(*) c FROM ap_blocks WHERE target = ?').get(DOEL).c, 0, 'de blokkade is weg');
+  const undo = bezorgd.find((b) => b.activiteit.type === 'Undo');
+  assert.ok(undo, 'er is een Undo bezorgd');
+  assert.equal(undo.activiteit.object.type, 'Block');
+  assert.equal(undo.activiteit.object.object, DOEL);
+});
+
+test('een DOMEIN-blokkade verstuurt niets: daar is geen inbox voor', async () => {
+  bezorgd.length = 0;
+  await AP.blockTarget(site(), 'spam.example');
+  assert.equal(db.prepare("SELECT count(*) c FROM ap_blocks WHERE kind = 'domain'").get().c, 1);
+  assert.equal(bezorgd.length, 0);
+});
+
+test('een onbereikbare tegenpartij houdt de blokkade niet tegen', async () => {
+  const stuk = globalThis.fetch;
+  globalThis.fetch = async () => { throw new Error('down'); };
+  const r = await AP.blockTarget(site(), 'https://203.0.113.99/ap/actor');
+  globalThis.fetch = stuk;
+  assert.equal(r.ok, true, 'de blokkade staat, ook zonder bezorging');
+  assert.equal(db.prepare('SELECT count(*) c FROM ap_blocks WHERE target = ?').get('https://203.0.113.99/ap/actor').c, 1);
+});
