Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision e2150909dd29bd970c606ecdc6b09f233415d3a8)
+++ src/routes/activitypub.js	(revision 383e8976c0201c4092866eefefb420ac6cedd57a)
@@ -562,5 +562,8 @@
   // dezelfde categorie als shaer:liked. Niet in totalItems persen: dat betekent
   // 'hoeveel er zijn' en niet 'hoeveel jij nog niet zag'.
-  const ongelezen = AP.unreadPerConversation(auth.site.slug);
+  const ongelezen = AP.unreadPerConversation(auth.site.slug, {
+    messagesAllowed: P.messagesAllowed,
+    guardians: (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })(),
+  });
   const gezien = new Set();
   for (const { head, item } of pairs) {
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision e2150909dd29bd970c606ecdc6b09f233415d3a8)
+++ src/services/ActivityPubService.js	(revision 383e8976c0201c4092866eefefb420ac6cedd57a)
@@ -4410,6 +4410,10 @@
  * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
  */
-export function unreadPerConversation(slug) {
-  try {
+export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {
+  try {
+    // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten
+    // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal
+    // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:
+    // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.
     const rijen = db.prepare(`
       SELECT u.other AS other,
@@ -4421,5 +4425,7 @@
        WHERE u.direction = 'in'
          AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
-       GROUP BY u.other`).all({ slug });
+         AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))
+       GROUP BY u.other`)
+      .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });
     return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
   } catch { return new Map(); }
Index: test/unread.test.js
===================================================================
--- test/unread.test.js	(revision e2150909dd29bd970c606ecdc6b09f233415d3a8)
+++ test/unread.test.js	(revision 383e8976c0201c4092866eefefb420ac6cedd57a)
@@ -71,2 +71,19 @@
   assert.equal(AP.markRead('kind', 'https://elders/n/bestaat-niet'), null);
 });
+
+test('een dichte messages-poort telt niet mee wat hij verbergt', () => {
+  // Anders vertelt het getal precies wat de poort verbergt: 'er zijn 3
+  // berichten' terwijl de app er geen laat zien.
+  const OPA = 'https://elders/u/opa';
+  arrived('https://elders/n/opa1', OPA, '2026-08-02T10:00:00Z');
+  arrived('https://elders/n/oom2', OOM, '2026-08-02T11:00:00Z');
+  db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, content, published, help_request)
+              VALUES ('kind', 'https://elders/n/boei', ?, 'iemand', '<p>help</p>', '2026-08-02T12:00:00Z', 1)`).run(OPA);
+
+  const dicht = AP.unreadPerConversation('kind', { messagesAllowed: false, guardians: new Set([OOM]) });
+  assert.equal(dicht.get(OPA).n, 1, 'alleen de boei van opa telt');
+  assert.ok(dicht.get(OOM), 'het guardian-kanaal gaat altijd door');
+
+  const open = AP.unreadPerConversation('kind', { messagesAllowed: true });
+  assert.equal(open.get(OPA).n, 2, 'met de poort open tellen ze allebei');
+});
