Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision f2a718154fecfedbd83c29cc9c9535632b990a86)
+++ src/config/database.js	(revision d14bf1c1b2981d0b3515c1738331a84dcb142db2)
@@ -771,4 +771,85 @@
   ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
   ensureColumn('ap_followers', 'icon', 'TEXT');    // avatar URL
+  feedStateTriggers();
+}
+
+/**
+ * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
+ *
+ * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
+ * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
+ * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
+ * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
+ * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
+ * ap_timeline.liked (shaer-9e9).
+ *
+ * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
+ * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
+ * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
+ *
+ * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
+ * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
+ * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
+ * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
+ * bij de tabel, en niet verspreid.
+ *
+ * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
+ * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
+ * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
+ */
+function feedStateTriggers() {
+  try {
+    db.exec(`
+      CREATE TABLE IF NOT EXISTS ap_feed_state (
+        slug TEXT NOT NULL,
+        object_uri TEXT NOT NULL,
+        rev INTEGER NOT NULL,
+        kind TEXT NOT NULL,             -- new | updated | deleted
+        at DATETIME DEFAULT CURRENT_TIMESTAMP,
+        PRIMARY KEY (slug, object_uri)
+      );
+      CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
+      -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
+      -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
+      -- client dat er niets gebeurd is.
+      CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
+    `);
+    if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
+      db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
+    }
+    // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
+    const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '') => `
+      DROP TRIGGER IF EXISTS ${naam};
+      CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel} BEGIN
+        UPDATE ap_feed_rev SET n = n + 1;
+        INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
+          ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
+          ON CONFLICT(slug, object_uri) DO UPDATE
+            SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
+      END;`;
+    const joinPosts = (uri, kind) => `
+          SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
+            FROM posts p JOIN sites s ON s.id = p.site_id`;
+    db.exec([
+      zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
+      zet('trg_feed_tl_upd', 'UPDATE OF content, media_json, nsfw, cw, url, poll_json, quote_json, embed_json', 'ap_timeline', 'NEW.slug', 'NEW.id', 'updated'),
+      zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
+      zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
+      zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
+      zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
+      zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
+      zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
+      zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
+      // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
+      // en vandaar dat deze drie niet in de gewone vorm passen.
+      zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`),
+      zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`),
+      zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`),
+    ].join('\n'));
+  } catch (e) {
+    // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
+    // volmaken", en dat is traag maar niet stuk.
+    console.error('❌ feed-state triggers:', e.message);
+  }
 }
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f2a718154fecfedbd83c29cc9c9535632b990a86)
+++ src/services/ActivityPubService.js	(revision d14bf1c1b2981d0b3515c1738331a84dcb142db2)
@@ -3502,13 +3502,25 @@
  */
 export function feedCursor(slug) {
-  const max = (sql, ...args) => { try { const r = db.prepare(sql).get(...args); return (r && r.n) || 0; } catch { return 0; } };
-  const t = max('SELECT MAX(rowid) AS n FROM ap_timeline WHERE slug = ?', slug);
-  const m = max('SELECT MAX(rowid) AS n FROM ap_mentions WHERE slug = ?', slug);
-  const o = max('SELECT MAX(rowid) AS n FROM ap_outbox WHERE site_slug = ?', slug);
-  const r = max(`SELECT MAX(i.rowid) AS n FROM ap_interactions i
-                   JOIN posts p ON p.id = i.post_id
-                   JOIN sites s ON s.id = p.site_id
-                  WHERE s.slug = ? AND i.kind = 'reply'`, slug);
-  return `${t}.${m}.${o}.${r}`;
+  try {
+    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
+    return String((r && r.n) || 0);
+  } catch { return '0'; }
+}
+
+/**
+ * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
+ * bewerkt, of weg.
+ *
+ * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
+ * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
+ * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
+ * plek waar ze samen te lezen zijn.
+ */
+export function feedChangesSince(slug, rev, limit = 200) {
+  try {
+    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
+                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
+      .all(slug, parseInt(rev, 10) || 0, limit);
+  } catch { return []; }
 }
 
@@ -5324,5 +5336,5 @@
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
-  feedCursor, waitForFeedChange,
+  feedCursor, feedChangesSince, waitForFeedChange,
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
Index: test/feed-wait.test.js
===================================================================
--- test/feed-wait.test.js	(revision f2a718154fecfedbd83c29cc9c9535632b990a86)
+++ test/feed-wait.test.js	(revision d14bf1c1b2981d0b3515c1738331a84dcb142db2)
@@ -57,4 +57,69 @@
     assert.notEqual(AP.feedCursor('me'), voor, `${naam} hoort de merksteen te bewegen`);
   }
+});
+
+test('een BEWERKING beweegt hem ook', () => {
+  // De reden dat de merksteen niet meer op MAX(rowid) leunt. Een Update schrijft
+  // dezelfde rij, dus de rowid bleef staan en een wachtende client sliep door.
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('bw','me','https://r.test/u/a','<p>oud</p>')").run();
+  const voor = AP.feedCursor('me');
+  db.prepare("UPDATE ap_timeline SET content = '<p>nieuw</p>' WHERE id = 'bw'").run();
+  assert.notEqual(AP.feedCursor('me'), voor);
+});
+
+test('en een VERWIJDERING, ook als het niet de laatste is', () => {
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('later','me','https://r.test/u/a','<p>x</p>')").run();
+  const voor = AP.feedCursor('me');
+  db.prepare("DELETE FROM ap_timeline WHERE id = 'bw'").run();
+  assert.notEqual(AP.feedCursor('me'), voor, 'anders blijft een verwijderde post staan tot je toevallig ververst');
+});
+
+test('maar een LIKE of BOOST van jezelf niet', () => {
+  // De val waar dit bijna in liep: een like schrijft ap_timeline.liked en een 🔁
+  // schrijft .boosted. Zonder de UPDATE OF-kolomlijst zou je eigen like het
+  // bericht als BEWERKT merken en elke wachtende client wekken.
+  const voor = AP.feedCursor('me');
+  db.prepare("UPDATE ap_timeline SET liked = 1 WHERE id = 'later'").run();
+  db.prepare("UPDATE ap_timeline SET boosted = 1 WHERE id = 'later'").run();
+  db.prepare("UPDATE ap_interactions SET acted_like = 1 WHERE post_id = 'p1'").run();
+  assert.equal(AP.feedCursor('me'), voor, 'je eigen reactie is geen nieuws');
+});
+
+test('een INSERT OR IGNORE die niets doet beweegt hem niet', () => {
+  const voor = AP.feedCursor('me');
+  db.prepare("INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, content) VALUES ('later','me','https://r.test/u/a','<p>x</p>')").run();
+  assert.equal(AP.feedCursor('me'), voor);
+});
+
+test('de teller loopt nooit achteruit', () => {
+  // Met MAX(rowid) zakte hij terug zodra de nieuwste rij verdween, en dan denkt
+  // een client dat er niets gebeurd is.
+  const hoog = parseInt(AP.feedCursor('me'), 10);
+  db.prepare('DELETE FROM ap_timeline WHERE slug = ?').run('me');
+  assert.ok(parseInt(AP.feedCursor('me'), 10) >= hoog);
+});
+
+test('en hij vertelt WAT er veranderd is', () => {
+  // Dit is wat een revisieteller niet kan en deze tabel gratis meegeeft: de
+  // "bewerkt"-markering hoeft er later geen eigen bouwsel voor te worden.
+  const stand = (uri) => (AP.feedChangesSince('me', 0).find((r) => r.object_uri === uri) || {}).kind;
+
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('vers','me','https://r.test/u/a','<p>x</p>')").run();
+  assert.equal(stand('vers'), 'new');
+
+  db.prepare("UPDATE ap_timeline SET content = '<p>anders</p>' WHERE id = 'vers'").run();
+  assert.equal(stand('vers'), 'updated');
+
+  db.prepare("DELETE FROM ap_timeline WHERE id = 'vers'").run();
+  assert.equal(stand('vers'), 'deleted');
+});
+
+test('het is een STAND en geen logboek: bewerkt-en-toen-weg leest als weg', () => {
+  // Een rij per bericht, niet een rij per gebeurtenis. Vijf keer bewerken blijft
+  // één rij, en dat is waarom er niets te snoeien valt. Mijn eerste test ging uit
+  // van een log en viel daar terecht over.
+  const alles = AP.feedChangesSince('me', 0);
+  const perUri = new Set(alles.map((r) => r.object_uri));
+  assert.equal(alles.length, perUri.size, 'geen enkel bericht komt twee keer voor');
 });
 
