Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
+++ src/config/database.js	(revision 32a4ffbf6c171e5c43e689f0c5f8887e6f791afb)
@@ -469,4 +469,16 @@
       UNIQUE(slug, actor_uri)
     );
+    -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
+    -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
+    -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
+    -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
+    -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
+    -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
+    -- antwoord, dus langer bewaren levert niets op.
+    CREATE TABLE IF NOT EXISTS ap_seen_notes (
+      uri TEXT PRIMARY KEY,
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
     CREATE TABLE IF NOT EXISTS ap_timeline (
       id TEXT NOT NULL,              -- the remote note's AP id
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
+++ src/services/ActivityPubService.js	(revision 32a4ffbf6c171e5c43e689f0c5f8887e6f791afb)
@@ -1715,7 +1715,42 @@
     if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
     if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
+    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
+    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
   } catch { /* bij twijfel niet ophalen */ }
   return false;
 }
+
+/**
+ * Onthoud dat we dit bericht al eens bezorgd kregen.
+ *
+ * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
+ * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
+ * stelt voordat er iets bij de bron wordt opgehaald.
+ *
+ * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
+ * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
+ * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
+ * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
+ * schrijvers die je zelf volgt.
+ */
+const SEEN_NOTES_DAYS = 30;
+let _seenSinceSnoei = 0;
+function rememberNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return;
+  try {
+    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
+    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
+    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
+    // ouder is dan een maand beantwoordt geen enkele vraag meer.
+    if (++_seenSinceSnoei >= 500) {
+      _seenSinceSnoei = 0;
+      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
+      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
+    }
+  } catch { /* niet fataal */ }
+}
+const isFollowedActor = (uri) => {
+  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
+};
 
 // Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
@@ -2124,4 +2159,14 @@
         console.log('[AP] timeline +', actorUri, 'x' + subs.length);
       }
+    }
+    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
+    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
+    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
+    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
+    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
+    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
+    // moet iemand zijn die je zelf bent gaan volgen.
+    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
+      rememberNoteUri(o.id);
     }
     // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
Index: test/seen-notes.test.js
===================================================================
--- test/seen-notes.test.js	(revision 32a4ffbf6c171e5c43e689f0c5f8887e6f791afb)
+++ test/seen-notes.test.js	(revision 32a4ffbf6c171e5c43e689f0c5f8887e6f791afb)
@@ -0,0 +1,122 @@
+// shaer-e9g: onthoud de URI van antwoorden die we al ontvangen.
+//
+// Antwoorden van accounts die je volgt komen gewoon binnen, ondertekend door de
+// schrijver zelf. Ze horen niet in de Krant (belongsInTimeline weigert alles met
+// een inReplyTo) en werden daarna nergens bewaard. Kwam er later een
+// DOORGESTUURD antwoord op zo'n bericht, dan kende Klonkt de ouder niet en wees
+// het af -- terwijl het die ouder wel degelijk had gehad. Op boiert ging daar 72%
+// van het doorstuurverkeer op stuk.
+//
+// Wat hier bewaakt wordt is vooral de grens: onthouden gebeurt ALLEEN voor
+// schrijvers die je zelf volgt. Zou dat voor iedereen gelden, dan kan een vreemde
+// eerst een bericht neerleggen en daarna met een antwoord daarop de dereference
+// naar een adres van zijn keuze sturen.
+//
+// Run: npm test
+
+import { test } 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');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+// IP-literals: safeFetch slaat de DNS-lookup over, dus offline.
+const GEVOLGD = 'https://203.0.113.10/users/catsalad';     // volgen we
+const VREEMDE = 'https://203.0.113.90/users/onbekend';     // volgen we niet
+const DOORSTUURDER = 'https://203.0.113.20/users/relay';
+
+db.prepare(`INSERT INTO ap_following (slug, actor_uri, handle, status) VALUES (?,?,?,?)`)
+  .run('me', GEVOLGD, '@catsalad@203.0.113.10', 'accepted');
+
+let bron = null;
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url) => {
+  const u = String(url);
+  if (bron && bron.id === u) {
+    return new Response(JSON.stringify(bron), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  }
+  return new Response('not found', { status: 404 });
+};
+
+const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
+const gezien = (uri) => !!db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ?').get(uri);
+
+/** Een gewoon bezorgd antwoord: ondertekend door de schrijver zelf. */
+function bezorgdAntwoord(auteur, id, inReplyTo) {
+  return {
+    type: 'Create',
+    actor: auteur,
+    object: {
+      id, type: 'Note', attributedTo: auteur, inReplyTo,
+      content: '<p>een antwoord midden in een draad</p>',
+      to: ['https://www.w3.org/ns/activitystreams#Public'],
+    },
+  };
+}
+
+test('een antwoord van iemand die we volgen wordt onthouden', async () => {
+  const uri = 'https://203.0.113.10/notes/catsalad-1';
+  const status = await AP.handleInbox(req(bezorgdAntwoord(GEVOLGD, uri, 'https://203.0.113.10/notes/draad-start')), 'me', { id: GEVOLGD });
+  assert.equal(status, 202);
+  assert.ok(gezien(uri), 'de URI hoort bewaard te zijn');
+});
+
+test('maar het verschijnt NIET in de Krant', () => {
+  // Alleen de URI. Er verandert niets aan wat je te zien krijgt -- een antwoord
+  // hoort in zijn draad, niet in de tijdlijn, en dat blijft zo.
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline').get().n, 0);
+});
+
+test('een antwoord van een VREEMDE wordt niet onthouden', async () => {
+  const uri = 'https://203.0.113.90/notes/vreemde-1';
+  await AP.handleInbox(req(bezorgdAntwoord(VREEMDE, uri, 'https://203.0.113.90/notes/iets')), 'me', { id: VREEMDE });
+  assert.equal(gezien(uri), false, 'anders kan een vreemde de poort voor zichzelf openzetten');
+});
+
+test('een gewone POST van iemand die we volgen wordt niet als gezien-antwoord bewaard', async () => {
+  // Die gaat naar de Krant en staat daarmee al in ap_timeline; een tweede
+  // administratie zou alleen maar uiteen kunnen lopen.
+  const uri = 'https://203.0.113.10/notes/gewone-post';
+  const act = bezorgdAntwoord(GEVOLGD, uri, null);
+  delete act.object.inReplyTo;
+  await AP.handleInbox(req(act), 'me', { id: GEVOLGD });
+  assert.equal(gezien(uri), false);
+  assert.ok(db.prepare('SELECT 1 FROM ap_timeline WHERE id = ?').get(uri), 'die hoort juist wel in de Krant');
+});
+
+test('en dan komt het doorgestuurde antwoord OP dat bericht wel binnen', async () => {
+  // De hele reden van deze bead. Zonder het onthouden was dit een afwijzing met
+  // "skipped (unknown inReplyTo)".
+  const ouder = 'https://203.0.113.10/notes/catsalad-1';   // hierboven onthouden
+  const antwoord = 'https://203.0.113.30/notes/drinkcoaster-1';
+  const schrijver = 'https://203.0.113.30/users/drinkcoaster';
+  bron = {
+    id: antwoord, type: 'Note', attributedTo: schrijver, inReplyTo: ouder,
+    content: '<p>ECHTE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const act = { type: 'Create', actor: schrijver, object: { ...bron, content: '<p>BEZORGDE versie</p>' } };
+  const status = await AP.handleInbox(req(act), 'me', { id: DOORSTUURDER });
+  assert.equal(status, 202, 'de doorgestuurde Create hoort geaccepteerd te worden');
+});
+
+test('een doorgestuurd antwoord op een bericht dat we NIET kenden blijft geweigerd', async () => {
+  const antwoord = 'https://203.0.113.30/notes/drinkcoaster-2';
+  const schrijver = 'https://203.0.113.30/users/drinkcoaster';
+  bron = {
+    id: antwoord, type: 'Note', attributedTo: schrijver, inReplyTo: 'https://203.0.113.90/notes/nooit-gezien',
+    content: '<p>x</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const act = { type: 'Create', actor: schrijver, object: bron };
+  assert.equal(await AP.handleInbox(req(act), 'me', { id: DOORSTUURDER }), 401);
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
