Index: scripts/backfill-reactions.mjs
===================================================================
--- scripts/backfill-reactions.mjs	(revision 6bc5ebd9709bc045a5b2da1ca0e7cd73a6d2285d)
+++ scripts/backfill-reactions.mjs	(revision 7842ca18b5f43af17b73058c3cd8879ef9421728)
@@ -1,67 +1,43 @@
 #!/usr/bin/env node
 //
-// Backfill: reacties uit de afgeleide kolommen naar de tussentabel (shaer-9e9).
+// Reactie-migratie handmatig draaien (shaer-9e9).
 //
-// "Heb ik hierop gereageerd" stond in Klonkt op twee plekken die door
-// verschillende routes werden gevuld: ap_timeline.liked/boosted (web-tijdlijn)
-// en ap_my_reactions (interact-pagina). Sinds fase 1 schrijft setReaction ze
-// allebei, maar alles van VOOR die wijziging staat nog maar in een van de twee.
-//
-// Dit script vult de tussentabel aan met wat alleen in de kolommen staat. Het
-// moet draaien VOORDAT de lezers naar de tussentabel wijzen (fase 2), anders
-// verdwijnen die reacties uit beeld -- stil, want een ontbrekende rij is geen
-// fout.
+// Normaal hoef je dit NIET: migrateReactions() draait bij boot, één keer per
+// REACTIONS_MIGRATION_VERSION-bump, net als de self-heal. Dit script is er om
+// vooraf te kijken wat er zou gebeuren, of om het gericht op één instance te
+// forceren.
 //
 //     node scripts/backfill-reactions.mjs --dry-run
-//     node scripts/backfill-reactions.mjs
+//     node scripts/backfill-reactions.mjs            # respecteert de versievlag
+//     node scripts/backfill-reactions.mjs --force    # ook als de vlag al staat
 //
-// Veilig om opnieuw te draaien: INSERT OR IGNORE plus de UNIQUE op
-// (site_slug, target_uri, kind) maakt het idempotent.
+// Bewust een schil om dezelfde functie die bij boot draait: twee implementaties
+// van een migratie lopen uiteen, en dan repareert de ene wat de andere niet ziet.
 //
-// Wat het NIET kan: de oorspronkelijke reactiedatum herstellen. Wanneer je
-// reageerde is nergens vastgelegd, dus de backfill-rijen krijgen de datum van
-// nu. Dat is geen verlies dat te repareren valt, wel iets om te weten als je
-// ooit op created_at gaat sorteren.
+// Wat het doet, en waarom allebei nodig is:
+//
+//   HERSLEUTELEN  De oude interact-route bewaarde de URI waarmee je binnenkwam,
+//                 en de bookmarklet geeft de permalink door. Sinds de reacties
+//                 op de canonieke object-URI gezocht worden, zouden die rijen
+//                 wees zijn. De created_at reist mee.
+//   AANVULLEN     Alles wat op oude code via de Krant is gegeven staat alleen in
+//                 ap_timeline.liked/boosted. Zonder deze stap toont het als
+//                 niet-gereageerd -- en klikt iemand opnieuw, met een tweede
+//                 Like de fediverse in als gevolg.
+//
+// Wat het NIET kan: bij AANVULLEN de oorspronkelijke reactiedatum herstellen.
+// Wanneer je reageerde is nergens vastgelegd, dus die rijen krijgen de datum van
+// nu. Bij hersleutelen blijft de datum wel behouden.
 
 import db from '../src/config/database.js';
+import { migrateReactions } from '../src/services/ActivityPubService.js';
 
 const dryRun = process.argv.includes('--dry-run');
+const force = process.argv.includes('--force');
 
-const scheef = (kind, kolom) => db.prepare(`
-  SELECT t.slug, t.id FROM ap_timeline t
-   WHERE t.${kolom} = 1
-     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 = ?)
-`).all(kind);
-
-const voor = {
+const meet = () => ({
   tussentabel: db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions').get().n,
   liked: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE liked = 1').get().n,
   boosted: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE boosted = 1').get().n,
-};
-const teDoen = { like: scheef('like', 'liked'), boost: scheef('boost', 'boosted') };
-
-console.log('vooraf :', JSON.stringify(voor));
-console.log('aan te vullen: like =', teDoen.like.length, ', boost =', teDoen.boost.length);
-
-if (dryRun) {
-  for (const [kind, rijen] of Object.entries(teDoen)) {
-    for (const r of rijen.slice(0, 10)) console.log(`  [dry-run] ${kind}  ${r.slug}  ${r.id}`);
-    if (rijen.length > 10) console.log(`  ... en nog ${rijen.length - 10}`);
-  }
-  console.log('\n--dry-run: niets geschreven.');
-  process.exit(0);
-}
-
-const ins = db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)');
-let toegevoegd = 0;
-db.transaction(() => {
-  for (const [kind, rijen] of Object.entries(teDoen)) {
-    for (const r of rijen) toegevoegd += ins.run(r.slug, r.id, kind).changes;
-  }
-})();
-
-const na = {
-  tussentabel: db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions').get().n,
   scheef: db.prepare(`
     SELECT COUNT(*) AS n FROM ap_timeline t
@@ -69,18 +45,44 @@
        AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
                         WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n,
-};
+  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,
+});
 
-console.log('toegevoegd:', toegevoegd);
-console.log('achteraf  :', JSON.stringify(na));
+const voor = meet();
+console.log('vooraf :', JSON.stringify(voor));
 
-// De controle die telt: is er nog een vlag zonder tegenhanger? Zo ja, dan is de
-// tussentabel nog niet veilig als bron en mag fase 2 niet.
+const uit = migrateReactions({ dryRun, force: force || dryRun });
+if (uit.overgeslagen) {
+  console.log('\novergeslagen: de versievlag staat al. Gebruik --force om toch te draaien.');
+  process.exit(0);
+}
+if (dryRun) {
+  console.log(`\n--dry-run: zou ${uit.hersleuteld} rij(en) hersleutelen en ${uit.aangevuld} aanvullen. Niets geschreven.`);
+  process.exit(0);
+}
+
+const na = meet();
+console.log('hersleuteld:', uit.hersleuteld, ' aangevuld:', uit.aangevuld);
+console.log('achteraf   :', JSON.stringify(na));
+
+// De twee controles die tellen. Blijft er een vlag zonder tegenhanger, dan is de
+// tussentabel niet compleet en tonen reacties als niet-gegeven. Blijft er een
+// tussentabel-rij zonder tijdlijnrij, dan is die op iets buiten je tijdlijn
+// gericht (legitiem) OF nog op een permalink (niet legitiem) -- vandaar de
+// waarschuwing in plaats van een fout.
 if (na.scheef !== 0) {
-  console.error(`\nLET OP: nog ${na.scheef} rij(en) met een vlag zonder tegenhanger. Fase 2 mag NIET.`);
+  console.error(`\nFOUT: nog ${na.scheef} rij(en) met een vlag zonder tegenhanger.`);
   process.exit(1);
 }
-if (toegevoegd !== teDoen.like.length + teDoen.boost.length) {
-  console.error('\nLET OP: het aantal toegevoegde rijen wijkt af van de meting vooraf.');
+if (na.wees > voor.wees) {
+  console.error('\nFOUT: er zijn tussentabel-rijen bijgekomen die nergens op slaan.');
   process.exit(1);
 }
-console.log('\nOK: elke vlag heeft nu een tegenhanger in de tussentabel.');
+if (na.wees) {
+  console.warn(`\nLET OP: ${na.wees} tussentabel-rij(en) zonder tijdlijnrij. Dat mag (een reactie op iets\n`
+    + 'buiten je tijdlijn), maar controleer of er geen permalinks tussen zitten van een post die je\n'
+    + 'wel kent -- die zouden hersleuteld moeten zijn.');
+}
+console.log('\nOK: elke vlag heeft een tegenhanger in de tussentabel.');
Index: src/server.js
===================================================================
--- src/server.js	(revision 6bc5ebd9709bc045a5b2da1ca0e7cd73a6d2285d)
+++ src/server.js	(revision 7842ca18b5f43af17b73058c3cd8879ef9421728)
@@ -65,5 +65,5 @@
 import apRoutes from './routes/activitypub.js';
 import oauthRoutes from './routes/oauth.js';
-import { apWants, startDeliveryWorker, selfHealTimeline } from './services/ActivityPubService.js';
+import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
 
 // SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
@@ -197,4 +197,8 @@
 startScheduler(); // release planning: publish scheduled posts when publish_at is reached
 startDeliveryWorker(); // retry failed fediverse deliveries with backoff
+// Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
+// de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
+// die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.
+migrateReactions();
 selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 6bc5ebd9709bc045a5b2da1ca0e7cd73a6d2285d)
+++ src/services/ActivityPubService.js	(revision 7842ca18b5f43af17b73058c3cd8879ef9421728)
@@ -3438,4 +3438,88 @@
  * werk voor fase 2, mét datamigratie.
  */
+// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
+// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
+// 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;
+
+/**
+ * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
+ *
+ * Twee stappen, en ze zijn allebei nodig:
+ *
+ *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
+ *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
+ *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
+ *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
+ *     wanneer je reageerde, bij aanvullen niet.
+ *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
+ *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
+ *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
+ *     fediverse in als gevolg.
+ *
+ * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
+ */
+export function migrateReactions(opts = {}) {
+  const uit = { hersleuteld: 0, aangevuld: 0, overgeslagen: false };
+  try {
+    if (!opts.force) {
+      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
+      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
+      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
+    }
+  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
+
+  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
+  const wees = `
+    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
+     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
+  const scheef = (kind, kolom) => `
+    FROM ap_timeline t
+     WHERE t.${kolom} = 1
+       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}')`;
+
+  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 */ }
+    return uit;
+  }
+
+  try {
+    db.transaction(() => {
+      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
+      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
+      //    oplevert en nooit een verdwenen reactie.
+      uit.hersleuteld = db.prepare(`
+        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
+        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
+      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
+
+      // 2. Aanvullen vanuit de kolommen.
+      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
+        uit.aangevuld += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
+      }
+    })();
+    if (uit.hersleuteld || uit.aangevuld) {
+      console.log(`[AP] reactie-migratie v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} hersleuteld, ${uit.aangevuld} aangevuld`);
+    }
+    if (!opts.force) {
+      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
+        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
+    }
+  } catch (e) {
+    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
+    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
+    console.warn('[AP] reactie-migratie mislukt:', e.message);
+  }
+  return uit;
+}
+
 /**
  * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
@@ -4888,5 +4972,5 @@
   gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
-  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
+  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Index: test/reactions-characterization.test.js
===================================================================
--- test/reactions-characterization.test.js	(revision 6bc5ebd9709bc045a5b2da1ca0e7cd73a6d2285d)
+++ test/reactions-characterization.test.js	(revision 7842ca18b5f43af17b73058c3cd8879ef9421728)
@@ -313,2 +313,52 @@
   assert.equal(AP.getReaction('me', onbekend).liked, true);
 });
+
+// ── De migratie bij boot ─────────────────────────────────────────────────
+
+test('migrateReactions hersleutelt een permalink-rij naar de object-URI', () => {
+  // De oude interact-route bewaarde de URI waarmee je binnenkwam, en de
+  // bookmarklet geeft de permalink door. Zonder hersleutelen zijn die rijen
+  // wees zodra er op de object-URI gezocht wordt.
+  const obj = uri('mig1');
+  const permalink = 'https://sound-fabrics.com/oud-artikel';
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
+              VALUES (?,?,?,?,?,?,?)`)
+    .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
+  db.prepare("INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at) VALUES (?,?,?,?)")
+    .run('me', permalink, 'like', '2026-07-01 12:00:00');
+
+  const uitkomst = AP.migrateReactions({ force: true });
+  assert.ok(uitkomst.hersleuteld >= 1);
+  assert.equal(AP.getReaction('me', obj).liked, true, 'nu vindbaar op de object-uri');
+  const rij = db.prepare('SELECT created_at FROM ap_my_reactions WHERE site_slug=? AND target_uri=? AND kind=?').get('me', obj, 'like');
+  assert.equal(rij.created_at, '2026-07-01 12:00:00', 'bij hersleutelen blijft de oorspronkelijke datum staan');
+  const oud = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE target_uri=?').get(permalink).n;
+  assert.equal(oud, 0, 'de permalink-rij is opgeruimd');
+});
+
+test('migrateReactions vult een kale kolomvlag aan', () => {
+  const u = uri('mig2'); seedTimeline(u);
+  AP.markLiked('me', u);                                  // zoals de oude Krant-route
+  assert.equal(AP.getReaction('me', u).liked, false, 'vooraf onzichtbaar');
+  AP.migrateReactions({ force: true });
+  assert.equal(AP.getReaction('me', u).liked, true, 'daarna zichtbaar');
+});
+
+test('migrateReactions is idempotent en respecteert de versievlag', () => {
+  const eerste = AP.migrateReactions({ force: true });
+  const tweede = AP.migrateReactions({ force: true });
+  assert.equal(tweede.hersleuteld, 0, 'niets meer te hersleutelen');
+  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);
+});
+
+test('migrateReactions --dry-run schrijft niets', () => {
+  const u = uri('mig3'); seedTimeline(u);
+  AP.markLiked('me', u);
+  const telling = AP.migrateReactions({ force: true, dryRun: true });
+  assert.ok(telling.aangevuld >= 1, 'hij ziet wel wat er te doen is');
+  assert.equal(AP.getReaction('me', u).liked, false, 'maar heeft niets geschreven');
+});
