Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 0824351cac00a52a6dc9c87b6c6fdf8668305079)
+++ src/services/ActivityPubService.js	(revision 4f19322d1a9fd1277644ad1f489f4fb18f9cba6c)
@@ -280,4 +280,24 @@
  * Funkwhale hoeft te weten.
  */
+/**
+ * De waarschuwingstekst van een object, of niets.
+ *
+ * `summary` IS in AS2 een SAMENVATTING -- "a natural language summarization of
+ * the object". Dat Mastodon dat veld hergebruikt als waarschuwing is Mastodons
+ * conventie, en die zet er `sensitive` bij. Zonder `sensitive` is een summary
+ * dus gewoon een samenvatting.
+ *
+ * WordPress + ActivityPub stuurt daar de EXCERPT van een artikel in, netjes
+ * afgekapt voor Mastodon. Wij lazen dat als waarschuwing en verborgen de post
+ * daarmee achter zijn eigen eerste alinea (Barts melding, 13-8:
+ * europeanpirates.eu). Niemand krijgt dan te zien wat er staat, en de
+ * waarschuwing waarschuwt nergens voor.
+ */
+export function contentWarning(o) {
+  if (!o || !o.sensitive) return null;
+  const s = typeof o.summary === 'string' ? o.summary.trim() : '';
+  return s || null;
+}
+
 export function timelineFields(o) {
   // De hoes: een `image` op het object. Bij een Note alleen als terugval (daar
@@ -308,5 +328,22 @@
   }
 
-  // Note / Article / Question -- ongewijzigd gedrag.
+  // Een ARTIKEL heeft een titel, en die is het eerste wat je wilt zien. Zonder
+  // dit kwam een WordPress-post binnen als kale body: de titel zit in `name` en
+  // die gooiden we weg, terwijl de excerpt in `summary` ten onrechte als
+  // waarschuwing dienstdeed. Nu allebei goed -- en dit is dezelfde greep die
+  // resolveRemoteNote al doet voor niet-Note-objecten, dus de tijdlijn en het
+  // antwoordpad zeggen eindelijk hetzelfde.
+  if (o.type && o.type !== 'Note' && typeof o.name === 'string' && o.name.trim()) {
+    const kop = `<p><strong>${HtmlSanitizerService.escape ? HtmlSanitizerService.escape(o.name) : o.name}</strong></p>`;
+    const atts = mediaFromNote(o);
+    const pagina = pickLink(o.url, (mt) => !mt || /html/i.test(mt));
+    return {
+      html: HtmlSanitizerService.sanitize(kop + (o.content || '')),
+      atts,
+      url: pagina ? pagina.href : null,
+    };
+  }
+
+  // Note / Question -- ongewijzigd gedrag.
   const atts = (Array.isArray(o.attachment) ? o.attachment : [])
     .map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }))
@@ -2463,5 +2500,5 @@
         // the timeline).
         for (const s of subs) {
-          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
+          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));
           // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
           if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
@@ -2615,5 +2652,5 @@
         // post would keep linking to the old, now-dead URL.
         const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
-          .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
+          .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
         if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
         // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
@@ -2679,5 +2716,5 @@
             // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
             let inserted = false;
-            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, bn.summary || null); inserted = r.changes > 0; } catch { /* ignore */ }
+            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }
             if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
             storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
@@ -3842,5 +3879,5 @@
     published: typeof o.published === 'string' ? o.published : undefined,
     sensitive: !!o.sensitive,
-    summary: typeof o.summary === 'string' ? o.summary.slice(0, 500) : undefined,
+    summary: (contentWarning(o) || '').slice(0, 500) || undefined,
     attachment: (() => {
       const arr = Array.isArray(o.attachment) ? o.attachment : (o.attachment ? [o.attachment] : []);
@@ -3958,5 +3995,5 @@
     content: HtmlSanitizerService.sanitize(rawHtml),       // full, sanitized
     sensitive: !!note.sensitive,                            // remote CW → blur in the Cirkel
-    cw: note.summary || '',
+    cw: contentWarning(note) || '',
     images,
     // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
@@ -4988,5 +5025,5 @@
 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone
 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
+const SELFHEAL_VERSION = 22; // v22: summary is pas een waarschuwing MET sensitive, en een artikel houdt zijn titel
 async function fetchNoteAP(url) {
   try {
@@ -5375,5 +5412,5 @@
       const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
       try {
-        const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, o.summary || null);
+        const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, contentWarning(o));
         if (r && r.changes > 0) added++;
         // FEP-9098: keep custom-emoji tags from backfilled posts too.
@@ -5534,8 +5571,14 @@
         if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
         if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
-        const html = HtmlSanitizerService.sanitize(note.content || '');
-        const media = mediaFromNote(note);
+        // Door DEZELFDE bouwer als de innamekant (v22). Hij bouwde de inhoud
+        // hier zelf op, en daardoor miste een gerepareerde rij precies wat de
+        // inname wel doet -- de titel van een artikel bijvoorbeeld. Een
+        // zelfherstel dat een andere vorm oplevert dan de inname repareert naar
+        // een derde toestand.
+        const velden = timelineFields(note);
+        const html = velden.html;
+        const media = velden.atts.length ? JSON.stringify(velden.atts) : mediaFromNote(note);
         const nsfw = note.sensitive ? 1 : 0;   // re-sync NSFW/sensitive + CW onto already-cached posts
-        const cw = note.summary || null;
+        const cw = contentWarning(note);
         const url = note.url || null;          // re-sync the human url (catches a remote slug rename)
         const emoji = extractEmojiTags(note.tag);   // FEP-9098: re-capture custom-emoji tags (v8)
@@ -6590,5 +6633,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, contentWarning, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
Index: test/summary-not-a-warning.test.js
===================================================================
--- test/summary-not-a-warning.test.js	(revision 4f19322d1a9fd1277644ad1f489f4fb18f9cba6c)
+++ test/summary-not-a-warning.test.js	(revision 4f19322d1a9fd1277644ad1f489f4fb18f9cba6c)
@@ -0,0 +1,68 @@
+// Een samenvatting is geen waarschuwing (Barts melding, 13-8).
+//
+// Posts van europeanpirates.eu (WordPress + ActivityPub) kwamen in Shaer binnen
+// achter een content warning, terwijl `sensitive` niet gezet is. De tekst van
+// die "waarschuwing" was de eerste alinea van de post zelf, afgekapt -- de
+// excerpt die WordPress voor Mastodon meestuurt.
+//
+// In AS2 IS summary een samenvatting: "a natural language summarization of the
+// object". Mastodon hergebruikt dat veld als waarschuwing en zet er sensitive
+// bij. Zonder sensitive is het dus gewoon een samenvatting, en die als
+// waarschuwing tonen verbergt de post achter zijn eigen tekst.
+//
+// In-memory SQLite. 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');
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+// De vorm zoals europeanpirates.eu hem stuurt, ingekort.
+const ARTIKEL = {
+  id: 'https://europeanpirates.eu/?p=5978',
+  type: 'Article',
+  name: 'The Ad Blocker You Chose is Being Removed for You',
+  summary: 'On 31 August 2026, the Chrome Web Store removes the last extensions built on Manifest V2, the older rulebook that...',
+  content: '<p>On 31 August 2026, the Chrome Web Store <a href="https://example.test">removes</a> the last extensions.</p>',
+  url: 'https://europeanpirates.eu/the-ad-blocker-you-chose-is-being-removed-for-you/',
+};
+
+test('zonder sensitive is een summary geen waarschuwing', () => {
+  assert.equal(AP.contentWarning(ARTIKEL), null);
+});
+
+test('met sensitive is hij dat wel, en dan is de tekst de waarschuwing', () => {
+  assert.equal(
+    AP.contentWarning({ ...ARTIKEL, sensitive: true }),
+    ARTIKEL.summary,
+  );
+});
+
+test('sensitive zonder tekst blijft leeg -- de vlag doet het werk', () => {
+  // Mastodon staat toe dat je alleen de media als gevoelig markeert. Dan is er
+  // niets te lezen en versluiert de vlag; een lege waarschuwing verzinnen zou
+  // een label zonder inhoud opleveren.
+  assert.equal(AP.contentWarning({ sensitive: true }), null);
+  assert.equal(AP.contentWarning({ sensitive: true, summary: '   ' }), null);
+});
+
+test('de titel van een artikel gaat niet verloren', () => {
+  // Zonder dit kwam een WordPress-post binnen als kale body: de titel zit in
+  // `name` en die gooiden we weg, terwijl de excerpt ten onrechte als
+  // waarschuwing dienstdeed. Allebei fout, en allebei zichtbaar in een post
+  // die er onherkenbaar uitzag.
+  const uit = AP.timelineFields(ARTIKEL);
+  assert.ok(uit.html.includes('The Ad Blocker You Chose is Being Removed for You'),
+    'de kop staat erin');
+  assert.ok(uit.html.includes('removes'), 'en de body ook');
+});
+
+test('een gewone Note verandert niet', () => {
+  const note = { id: 'x', type: 'Note', content: '<p>hoi</p>' };
+  assert.equal(AP.timelineFields(note).html, '<p>hoi</p>');
+  assert.equal(AP.contentWarning(note), null);
+});
