Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 68a4d1be062424c6e3fd9e56ce38c67ba4a5549b)
+++ src/services/ActivityPubService.js	(revision 6bc5ebd9709bc045a5b2da1ca0e7cd73a6d2285d)
@@ -3439,4 +3439,30 @@
  */
 /**
+ * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
+ *
+ * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
+ * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
+ * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
+ * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
+ * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
+ *
+ * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
+ * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
+ * highlight. Vandaar hier, en niet later.
+ *
+ * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
+ * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
+ * een reactie op iets buiten je tijdlijn moet gewoon werken.
+ */
+export function canonicalReactionUri(slug, uri) {
+  if (!slug || !uri) return uri;
+  try {
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
+    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
+    return (row && row.id) || uri;
+  } catch { return uri; }
+}
+
+/**
  * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
  * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
@@ -3445,6 +3471,7 @@
 export function getReaction(slug, uri) {
   try {
-    const rows = (slug && uri)
-      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri)
+    const key = canonicalReactionUri(slug, uri);
+    const rows = (slug && key)
+      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
       : [];
     return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
@@ -3478,6 +3505,11 @@
 export function setReaction(slug, uri, kind, on, opts = {}) {
   if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
-  setMyReaction(slug, uri, kind, !!on);
-  const flagUri = opts.flagUri || uri;
+  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
+  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
+  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
+  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
+  // maakte dezelfde like onvindbaar vanaf de andere pagina.
+  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
+  setMyReaction(slug, flagUri, kind, !!on);
   if (kind === 'boost') {
     if (!on) unmarkBoosted(slug, flagUri);
@@ -4856,5 +4888,5 @@
   gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
-  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
+  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, 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 68a4d1be062424c6e3fd9e56ce38c67ba4a5549b)
+++ test/reactions-characterization.test.js	(revision 6bc5ebd9709bc045a5b2da1ca0e7cd73a6d2285d)
@@ -193,14 +193,15 @@
 });
 
-test('setReaction: gescheiden sleutels blijven werken zolang ze bestaan', () => {
-  // De tussentabel wordt gesleuteld op wat de client stuurde, de vlag op de
-  // opgeloste object-URI. Meestal gelijk, niet gegarandeerd. flagUri houdt dat
-  // uit elkaar tot fase 2 ze samentrekt.
+test('setReaction: flagUri bepaalt de sleutel voor BEIDE bronnen', () => {
+  // Vroeger kreeg de tussentabel de URI die de client stuurde en de vlag de
+  // opgeloste. Nu is het er één, anders is dezelfde like onvindbaar vanaf een
+  // pagina die de andere URI kent.
   const gestuurd = 'https://r.test/@anna/123';
   const opgelost = uri('sr3');
   seedTimeline(opgelost);
   AP.setReaction('me', gestuurd, 'like', true, { flagUri: opgelost });
-  assert.equal(AP.getMyReactions('me', gestuurd).liked, true, 'op de gestuurde uri');
-  assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'op de opgeloste uri');
+  assert.equal(AP.getReaction('me', opgelost).liked, true, 'onder de opgeloste uri');
+  assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'en de vlag ook');
+  assert.equal(AP.getMyReactions('me', gestuurd).liked, false, 'niet meer onder de gestuurde');
 });
 
@@ -273,2 +274,41 @@
   }
 });
+
+// ── De permalink en de object-URI zijn dezelfde post ─────────────────────
+
+test('een like uit de Krant is zichtbaar op de interact-pagina (Robins melding)', () => {
+  // De Krant kent een post als AP-object-URI, de interact-pagina als permalink.
+  // Zochten die twee in verschillende sleutelruimtes, dan toonde een geboost en
+  // geliket bericht daar geen enkele highlight -- terwijl de reactie bestond.
+  const obj = uri('perm1');
+  const permalink = 'https://sound-fabrics.com/effortlesseffect';
+  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');
+
+  AP.setReaction('me', obj, 'like', true);      // zoals de Krant het doet
+  AP.setReaction('me', obj, 'boost', true);
+  // en de interact-pagina vraagt het met de permalink:
+  assert.deepEqual(AP.getReaction('me', permalink), { liked: true, boosted: true });
+});
+
+test('andersom net zo: reageren via de permalink landt op de object-URI', () => {
+  const obj = uri('perm2');
+  const permalink = 'https://sound-fabrics.com/tweede';
+  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/bo', 'Bo', '<p>y</p>', permalink, '2026-08-06 09:00:00');
+
+  AP.setReaction('me', permalink, 'like', true);   // zoals de interact-pagina het doet
+  assert.equal(AP.getReaction('me', obj).liked, true, 'onder de object-uri opgeslagen');
+  assert.equal(AP.getTimelineReaction('me', obj).liked, true, 'dus de vlag landt ook goed');
+  assert.equal(AP.getReaction('me', permalink).liked, true, 'en blijft vindbaar via de permalink');
+});
+
+test('een reactie op iets buiten je tijdlijn blijft gewoon werken', () => {
+  // Kennen we de post niet, dan is er niets te canoniseren en blijft de invoer
+  // de sleutel. Een like op een vreemde post mag daar niet op stuklopen.
+  const onbekend = 'https://elders.test/notes/xyz';
+  AP.setReaction('me', onbekend, 'like', true);
+  assert.equal(AP.getReaction('me', onbekend).liked, true);
+});
