Index: scripts/backfill-reactions.mjs
===================================================================
--- scripts/backfill-reactions.mjs	(revision bedd7a6cac5de300b43353212b411a1f4777d295)
+++ scripts/backfill-reactions.mjs	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
@@ -25,4 +25,7 @@
 //                 niet-gereageerd -- en klikt iemand opnieuw, met een tweede
 //                 Like de fediverse in als gevolg.
+//   UIT REACTIES  De derde bron (shaer-ipb): ap_interactions.acted_like/_boost,
+//                 wat jij deed met een reactie ONDER je eigen post. Zelfde
+//                 gevolg als hierboven, alleen op een ander oppervlak.
 //
 // Wat het NIET kan: bij AANVULLEN de oorspronkelijke reactiedatum herstellen.
@@ -45,8 +48,19 @@
        AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
                         WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n,
+  // Een reactie op een COMMENT hoort geen tijdlijnrij te hebben, dus die telt
+  // hier niet als wees -- anders slaat de controle hieronder alarm op precies
+  // wat stap 3 net goed heeft gezet.
   wees: db.prepare(`
     SELECT COUNT(*) AS n FROM ap_my_reactions r
      WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t
-                        WHERE t.slug = r.site_slug AND t.id = r.target_uri)`).get().n,
+                        WHERE t.slug = r.site_slug AND t.id = r.target_uri)
+       AND NOT EXISTS (SELECT 1 FROM ap_interactions i WHERE i.object_uri = r.target_uri)`).get().n,
+  scheefActed: db.prepare(`
+    SELECT COUNT(*) 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 (i.acted_like = 1 OR i.acted_boost = 1) AND IFNULL(i.object_uri, '') <> ''
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri)`).get().n,
 });
 
@@ -60,10 +74,11 @@
 }
 if (dryRun) {
-  console.log(`\n--dry-run: zou ${uit.hersleuteld} rij(en) hersleutelen en ${uit.aangevuld} aanvullen. Niets geschreven.`);
+  console.log(`\n--dry-run: zou ${uit.hersleuteld} rij(en) hersleutelen, ${uit.aangevuld} aanvullen`
+    + ` en ${uit.reacties} uit reacties overnemen. Niets geschreven.`);
   process.exit(0);
 }
 
 const na = meet();
-console.log('hersleuteld:', uit.hersleuteld, ' aangevuld:', uit.aangevuld);
+console.log('hersleuteld:', uit.hersleuteld, ' aangevuld:', uit.aangevuld, ' uit reacties:', uit.reacties);
 console.log('achteraf   :', JSON.stringify(na));
 
@@ -75,4 +90,8 @@
 if (na.scheef !== 0) {
   console.error(`\nFOUT: nog ${na.scheef} rij(en) met een vlag zonder tegenhanger.`);
+  process.exit(1);
+}
+if (na.scheefActed !== 0) {
+  console.error(`\nFOUT: nog ${na.scheefActed} reactie(s) onder je eigen posts met acted_* zonder tegenhanger.`);
   process.exit(1);
 }
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision bedd7a6cac5de300b43353212b411a1f4777d295)
+++ src/routes/posts.js	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
@@ -1557,17 +1557,19 @@
   const kind = req.body.kind === 'boost' ? 'boost' : 'like';
   if (parent && parent.post_id === post.id && parent.object_uri) {
-    if (kind === 'boost') {
-      // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
-      const on = !parent.acted_boost;
-      ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
-        .catch((e) => console.warn('[AP] reaction failed:', e.message));
-      ActivityPubService.setInteractionBoosted(parent.id, on);
-    } else {
-      // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
-      const on = !parent.acted_like;
-      ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
-        .catch((e) => console.warn('[AP] reaction failed:', e.message));
-      ActivityPubService.setInteractionLiked(parent.id, on);
-    }
+    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
+    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
+    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
+    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
+    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
+    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
+    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
+      .catch((e) => console.warn('[AP] reaction failed:', e.message));
+    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
+    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
+    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
+    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
+    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
+    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
+    else ActivityPubService.setInteractionLiked(parent.id, on);
   }
   res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision bedd7a6cac5de300b43353212b411a1f4777d295)
+++ src/services/ActivityPubService.js	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
@@ -1186,7 +1186,20 @@
   const siteIcon = (site && site.profile_photo) || null;
 
+  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
+  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
+  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
+  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
+  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
+  const mijnSleutel = new Map();
+  for (const r of rows) {
+    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
+  }
+  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
+  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
+
   const nodes = [];
   for (const r of rows) {
     if (r.kind !== 'reply') continue;
+    const ik = mijnReactie(r.object_uri);
     nodes.push({
       noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
@@ -1195,5 +1208,5 @@
       actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
       emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
-      acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
+      acted_boost: ik.boosted, acted_like: ik.liked,
       children: [],
     });
@@ -3646,5 +3659,7 @@
 // stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
 // terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
-const REACTIONS_MIGRATION_VERSION = 1;
+// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
+// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
+const REACTIONS_MIGRATION_VERSION = 2;
 
 /**
@@ -3666,5 +3681,5 @@
  */
 export function migrateReactions(opts = {}) {
-  const uit = { hersleuteld: 0, aangevuld: 0, overgeslagen: false };
+  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
   try {
     if (!opts.force) {
@@ -3684,11 +3699,21 @@
        AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
                         WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
+  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
+  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
+  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
+  //    dus die uitsluiting verliest per constructie niets.
+  const acted = (kind, kolom) => `
+    FROM ap_interactions i
+     JOIN posts p ON p.id = i.post_id
+     JOIN sites s ON s.id = p.site_id
+     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
 
   if (opts.dryRun) {
-    try {
-      uit.hersleuteld = db.prepare(`SELECT COUNT(*) AS n ${wees}`).get().n;
-      uit.aangevuld = db.prepare(`SELECT COUNT(*) AS n ${scheef('like', 'liked')}`).get().n
-                    + db.prepare(`SELECT COUNT(*) AS n ${scheef('boost', 'boosted')}`).get().n;
-    } catch { /* laat de nullen staan */ }
+    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
+    uit.hersleuteld = tel(wees);
+    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
+    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
     return uit;
   }
@@ -3710,7 +3735,14 @@
           SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
       }
+
+      // 3. En vanuit acted_* op de reacties onder je eigen posts.
+      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
+        uit.reacties += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
+      }
     })();
-    if (uit.hersleuteld || uit.aangevuld) {
-      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled`);
+    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
+      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
     }
     if (!opts.force) {
Index: test/interaction-reactions.test.js
===================================================================
--- test/interaction-reactions.test.js	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
+++ test/interaction-reactions.test.js	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
@@ -0,0 +1,102 @@
+// shaer-ipb: ap_interactions.acted_* was de DERDE bron van "heb ik hierop
+// gereageerd", naast ap_timeline.liked/boosted en ap_my_reactions. shaer-9e9
+// trok de eerste twee samen en liet deze staan, omdat hij op het interactie-rij-
+// id gesleuteld was en de tussentabel op de object-URI.
+//
+// Nu is ook deze bron de tussentabel. Wat hier bewaakt wordt:
+//   - de migratie neemt bestaande acted_*-rijen mee
+//   - getInteractions leest uit de tussentabel, niet meer uit de kolom
+//   - dezelfde note die je als comment EN als post kent, is EEN feit
+//
+// 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');
+db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+  .run('p1', 's1', 'mijn-post', 'u1', 'Mijn post', '<p>x</p>', 'published');
+
+const site = { slug: 'me', title: 'Me' };
+const COMMENT = 'https://203.0.113.60/notes/comment-1';
+const COMMENT2 = 'https://203.0.113.60/notes/comment-2';
+
+function comment(uri, extra = {}) {
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, acted_like, acted_boost)
+              VALUES ('reply', 'p1', ?, ?, 'Anna', '@anna@203.0.113.60', '<p>hoi</p>', ?, ?)`)
+    .run(uri, 'https://203.0.113.60/users/anna', extra.like ? 1 : 0, extra.boost ? 1 : 0);
+  return db.prepare('SELECT id FROM ap_interactions WHERE object_uri = ?').get(uri).id;
+}
+
+const knop = (uri) => {
+  const plat = [];
+  const loop = (ns) => ns.forEach((n) => { plat.push(n); loop(n.children || []); });
+  loop(AP.getInteractions('p1', 'https://klonkt.test', site).thread);
+  const n = plat.find((x) => x.noteId === uri);
+  assert.ok(n, `node ${uri} hoort in de thread te staan`);
+  return { like: n.acted_like, boost: n.acted_boost };
+};
+
+test('de migratie neemt acted_* mee naar de tussentabel', () => {
+  comment(COMMENT, { like: true });
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 1, 'één reactie hoort overgenomen te worden');
+  assert.deepEqual(AP.getReaction('me', COMMENT), { liked: true, boosted: false });
+});
+
+test('en is idempotent: nog een keer draaien voegt niets toe', () => {
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 0);
+});
+
+test('de knopstand komt uit de tussentabel, niet uit de kolom', () => {
+  // Zet de kolom en de tussentabel EXPLICIET tegenover elkaar. Zou de view nog
+  // uit de kolom lezen, dan zou hier true uit komen.
+  db.prepare('UPDATE ap_interactions SET acted_boost = 1 WHERE object_uri = ?').run(COMMENT);
+  assert.equal(knop(COMMENT).boost, false, 'de kolom liegt en wordt genegeerd');
+  AP.setReaction('me', COMMENT, 'boost', true);
+  assert.equal(knop(COMMENT).boost, true);
+});
+
+test('een reactie geven en intrekken laat niets achter', () => {
+  comment(COMMENT2);
+  assert.deepEqual(knop(COMMENT2), { like: false, boost: false });
+  AP.setReaction('me', COMMENT2, 'like', true);
+  assert.equal(knop(COMMENT2).like, true);
+  AP.setReaction('me', COMMENT2, 'like', false);
+  assert.equal(knop(COMMENT2).like, false);
+});
+
+test('dezelfde note als comment en als post is EEN feit', () => {
+  // De betekeniswijziging van deze bead, expliciet vastgelegd: like je een note
+  // in je Krant, dan staat de knop onder diezelfde note als comment ook aan.
+  // Twee knoppen die los van elkaar aan konden staan voor hetzelfde object was
+  // eerder een bug dan een feature -- er gaat immers één Like de fediverse in.
+  const BEIDE = 'https://203.0.113.60/notes/overlap';
+  comment(BEIDE);
+  db.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+              VALUES (?,?,?,?,?,?)`).run(BEIDE, 'me', 'https://203.0.113.60/users/anna', 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
+  AP.setReaction('me', BEIDE, 'like', true);
+  assert.equal(knop(BEIDE).like, true, 'de like uit de Krant hoort onder de comment te staan');
+});
+
+test('een reactie zonder object_uri blokkeert de migratie niet', () => {
+  // Een like of announce draagt geen object_uri, en fedi-react eist er een --
+  // zulke rijen kunnen dus nooit acted_* dragen. De migratie moet ze overslaan
+  // in plaats van erover te struikelen. De kolom is NOT NULL, dus zo'n rij draagt
+  // een lege string; zo staat hij ook op beta.
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, acted_like)
+              VALUES ('like', 'p1', '', ?, 'Bob', 1)`).run('https://203.0.113.60/users/bob');
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 0, 'niets over te nemen, en geen fout');
+});
Index: test/reactions-characterization.test.js
===================================================================
--- test/reactions-characterization.test.js	(revision bedd7a6cac5de300b43353212b411a1f4777d295)
+++ test/reactions-characterization.test.js	(revision 279ca0b1dee1c650e41b6026c5256ca31432843a)
@@ -362,7 +362,13 @@
   assert.equal(tweede.aangevuld, 0, 'niets meer aan te vullen');
   assert.ok(eerste.hersleuteld >= 0);
-  // Zonder force draait hij niet nog eens zodra de vlag staat.
-  db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?,?)').run('reactions_migration_version', '1');
-  assert.equal(AP.migrateReactions().overgeslagen, true);
+  // De vlag is VERSIEgebonden, en niet aan één vast getal: een oudere vlag laat
+  // de migratie opnieuw lopen -- zo doet een bump als v2 zijn werk -- en een
+  // gelijke of hogere slaat hem over. Een hardgecodeerd versienummer hier zou bij
+  // elke bump omvallen zonder dat er iets stuk is.
+  const vlag = (v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?,?)').run('reactions_migration_version', v);
+  vlag('1');
+  assert.equal(AP.migrateReactions().overgeslagen, false, 'een oudere vlag laat hem opnieuw lopen');
+  vlag('999');
+  assert.equal(AP.migrateReactions().overgeslagen, true, 'een gelijke of hogere vlag slaat over');
 });
 
