Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 41de7cb2af5077249f1c033243c7dd8dc392d9ab)
+++ src/config/database.js	(revision 21ef238b2aef0be15654f3a397db52757fa168b1)
@@ -899,4 +899,22 @@
   try {
     db.exec(`
+      -- Tot waar jij een gesprek gelezen hebt (shaer-frontend-3tx).
+      --
+      -- Een MARKERING, geen teller: het aantal ongelezen berichten is een
+      -- COUNT over de berichten die na deze cursor komen. Een opgeslagen
+      -- getal zou opgehoogd, verlaagd en gerepareerd moeten worden, en zou
+      -- blijven staan als er iets verwijderd wordt -- badge zegt 3, er is
+      -- niets.
+      --
+      -- De cursor is samengesteld ('<stempel>|<ref>'), dezelfde vorm als de
+      -- gesprekspaginering en om dezelfde reden: twee berichten in dezelfde
+      -- seconde is bij DM's een gesprek, geen randgeval.
+      CREATE TABLE IF NOT EXISTS ap_read_markers (
+        slug TEXT NOT NULL,
+        other TEXT NOT NULL,          -- de tegenpartij (actor uri)
+        cursor TEXT NOT NULL,
+        at DATETIME DEFAULT CURRENT_TIMESTAMP,
+        PRIMARY KEY (slug, other)
+      );
       CREATE TABLE IF NOT EXISTS ap_feed_state (
         slug TEXT NOT NULL,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 41de7cb2af5077249f1c033243c7dd8dc392d9ab)
+++ src/routes/activitypub.js	(revision 21ef238b2aef0be15654f3a397db52757fa168b1)
@@ -492,5 +492,9 @@
   const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
     .map((m) => [m.object_uri, m]));
-  const items = [];
+  // PAREN, geen losse lijst: een kop levert niet altijd een item op (dichte
+  // poort, ontbrekende rij), en dan zou de aanroeper op index koppelen en de
+  // telling aan het verkeerde gesprek hangen. Stil, en pas te zien als iemand
+  // een badge op de verkeerde naam ziet staan.
+  const pairs = [];
   for (const r of refs) {
     if (r.direction === 'in') {
@@ -498,13 +502,13 @@
       if (!m) continue;
       if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
-      items.push(messageItem(m, ctx));
+      pairs.push({ head: r, item: messageItem(m, ctx) });
     } else {
       const n = AP.getOutboxNote(base, r.ref);
       // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
       // niet wat je zelf gezegd hebt.
-      if (n) items.push(sentItem(n, { me, mine }));
+      if (n) pairs.push({ head: r, item: sentItem(n, { me, mine }) });
     }
   }
-  return items;
+  return pairs;
 }
 
@@ -513,5 +517,25 @@
   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
   const heads = AP.conversationHeads(auth.site.slug);
-  const items = conversationItems(req, auth, heads);
+  const pairs = conversationItems(req, auth, heads);
+  const items = pairs.map((x) => x.item);
+  // Ongelezen per gesprek (shaer-frontend-3tx): een COUNT, geen bijgehouden
+  // getal. Hij hangt aan het NIEUWSTE kopje van elke persoon -- er kunnen er
+  // twee zijn (zie conversationHeads) en het aantal hoort bij het gesprek, niet
+  // bij een bericht.
+  //
+  // AS2 heeft geen term voor ongelezen; dit is per-lezer-interactiestatus,
+  // 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 gezien = new Set();
+  for (const { head, item } of pairs) {
+    if (gezien.has(head.other)) continue;
+    gezien.add(head.other);
+    const u = ongelezen.get(head.other);
+    if (!u) continue;
+    item.object['shaer:unread'] = u.n;
+    // Een zwaai is geen aantal maar een zetje van een guardian: eigen teken.
+    if (u.wave) item.object['shaer:unreadWave'] = true;
+  }
   AP.sendAP(res, {
     '@context': AP.AP_CONTEXT,
@@ -533,5 +557,5 @@
     limit: req.query.limit,
   });
-  const items = conversationItems(req, auth, page.rows);
+  const items = conversationItems(req, auth, page.rows).map((x) => x.item);
   // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
   // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 41de7cb2af5077249f1c033243c7dd8dc392d9ab)
+++ src/services/ActivityPubService.js	(revision 21ef238b2aef0be15654f3a397db52757fa168b1)
@@ -3238,4 +3238,18 @@
         return await c2sCreatePost(base, site, user, object);
       }
+      // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
+      //
+      // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
+      // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
+      // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
+      // leesbevestiging heeft in de fediverse niets te zoeken.
+      case 'Read': {
+        const targetUri = c2sIdOf(object);
+        if (!targetUri) return { status: 400, error: 'missing_object' };
+        const uit = markRead(site.slug, targetUri);
+        // Kennen we die note niet, dan is er niets gelezen om te onthouden.
+        // Geen fout: een client mag best een oud bericht aanwijzen.
+        return { status: uit ? 200 : 202 };
+      }
       case 'Like':
       case 'Announce': {
@@ -4348,4 +4362,68 @@
 }
 
+/**
+ * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
+ *
+ * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
+ * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
+ * geen regel die iemand moet onthouden maar een eigenschap van het model --
+ * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
+ * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
+ */
+export function readMarkers(slug) {
+  try {
+    return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
+      .all(slug).map((r) => [r.other, r.cursor]));
+  } catch { return new Map(); }
+}
+
+/**
+ * Markeer een gesprek als gelezen tot en met dit bericht.
+ *
+ * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
+ * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
+ * en kan er ook niet naast zitten.
+ */
+export function markRead(slug, objectUri) {
+  try {
+    const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
+                             WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
+      .get({ slug, ref: String(objectUri || '') });
+    if (!rij) return null;
+    const cursor = `${rij.stamp}|${rij.ref}`;
+    db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
+                ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
+      .run(slug, rij.other, cursor);
+    return { other: rij.other, cursor };
+  } catch { return null; }
+}
+
+/**
+ * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
+ *
+ * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
+ * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
+ * het vanzelf weer.
+ *
+ * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
+ * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
+ * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
+ */
+export function unreadPerConversation(slug) {
+  try {
+    const rijen = db.prepare(`
+      SELECT u.other AS other,
+             COUNT(*) AS n,
+             MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
+        FROM (${CONVERSATION_UNION}) u
+        LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
+        LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
+       WHERE u.direction = 'in'
+         AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
+       GROUP BY u.other`).all({ slug });
+    return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
+  } catch { return new Map(); }
+}
+
 export function getDirectMessages(slug, limit) {
   try {
@@ -6441,5 +6519,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, 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, 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/unread.test.js
===================================================================
--- test/unread.test.js	(revision 21ef238b2aef0be15654f3a397db52757fa168b1)
+++ test/unread.test.js	(revision 21ef238b2aef0be15654f3a397db52757fa168b1)
@@ -0,0 +1,72 @@
+// De badge: ongelezen per gesprek (shaer-frontend-3tx).
+//
+// Twee dingen liggen hier vast. Een COUNT en geen bijgehouden getal -- bij een
+// verwijdering klopt het dan vanzelf weer, waar een teller zou blijven staan.
+// En Read als GEBEURTENIS: markeren is optellend, dus een toestel dat een week
+// uit stond kan je gelezen berichten niet terugzetten op ongelezen.
+//
+// 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');
+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@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
+
+const OMA = 'https://elders/u/oma';
+const OOM = 'https://elders/u/oom';
+const arrived = (uri, from, stamp, wave = 0) => db.prepare(
+  `INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, content, published, wave)
+   VALUES ('kind', ?, ?, 'iemand', '<p>hoi</p>', ?, ?)`).run(uri, from, stamp, wave);
+
+arrived('https://elders/n/1', OMA, '2026-08-01T10:00:00Z');
+arrived('https://elders/n/2', OMA, '2026-08-01T11:00:00Z');
+arrived('https://elders/n/3', OMA, '2026-08-01T12:00:00Z');
+arrived('https://elders/n/w', OOM, '2026-08-01T09:00:00Z', 1);
+
+test('alles is ongelezen tot je iets gelezen hebt', () => {
+  const u = AP.unreadPerConversation('kind');
+  assert.equal(u.get(OMA).n, 3);
+  assert.equal(u.get(OOM).n, 1);
+  // Een zwaai telt apart: dat is een zetje van een guardian, geen gesprek.
+  assert.equal(u.get(OOM).wave, true);
+  assert.equal(u.get(OMA).wave, false);
+});
+
+test('lezen tot het tweede bericht laat er een over', () => {
+  AP.markRead('kind', 'https://elders/n/2');
+  assert.equal(AP.unreadPerConversation('kind').get(OMA).n, 1);
+  assert.equal(AP.unreadPerConversation('kind').get(OOM).n, 1, 'een ander gesprek raakt het niet');
+});
+
+test('een oud toestel kan gelezen berichten niet terugzetten', () => {
+  // Precies het geval waar Read-als-gebeurtenis voor is: markeren is
+  // optellend, dus dit doet niets.
+  AP.markRead('kind', 'https://elders/n/1');
+  assert.equal(AP.unreadPerConversation('kind').get(OMA).n, 1, 'nog steeds een');
+});
+
+test('alles gelezen is geen rij, niet een nul', () => {
+  AP.markRead('kind', 'https://elders/n/3');
+  assert.equal(AP.unreadPerConversation('kind').get(OMA), undefined);
+});
+
+test('een verwijdering laat het getal vanzelf kloppen', () => {
+  // Waar een bijgehouden teller zou blijven staan: badge zegt 1, er is niets.
+  arrived('https://elders/n/4', OMA, '2026-08-01T13:00:00Z');
+  assert.equal(AP.unreadPerConversation('kind').get(OMA).n, 1);
+  db.prepare("DELETE FROM ap_mentions WHERE object_uri = ?").run('https://elders/n/4');
+  assert.equal(AP.unreadPerConversation('kind').get(OMA), undefined);
+});
+
+test('een onbekende note markeert niets, en dat is geen fout', () => {
+  assert.equal(AP.markRead('kind', 'https://elders/n/bestaat-niet'), null);
+});
