Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 02fb7ce9e41008ada5f1e2fa8289da2cc7ad7687)
+++ src/routes/posts.js	(revision c010b423494784ff556a97a64ba69c3c733ffe0e)
@@ -843,5 +843,6 @@
       .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
       .catch((e) => console.warn('[AP] remote like failed:', e.message));
-    ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
+    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
+    ActivityPubService.setReaction(site.slug, uri, 'like', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -862,9 +863,11 @@
         const id = note.object_uri || uri;
         return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
-          // Boost → store the post in the timeline (even if you don't follow the author) so it
-          // surfaces in the Cirkel; unboost → just clear the flag.
-          .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
+          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
+          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
+          // de Cirkel verschijnt.
+          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
       })
       .catch((e) => console.warn('[AP] remote boost failed:', e.message));
+    // De tussentabel meteen, zodat de knop klopt voordat de resolve terug is.
     ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
   }
@@ -1279,5 +1282,5 @@
     on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
     try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
+    ActivityPubService.setReaction(site.slug, note, 'like', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -1293,14 +1296,12 @@
     on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
     try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
+    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
     if (on) {
-      ActivityPubService.markBoosted(site.slug, note); // instant UI state
       // Fire-and-forget: re-resolve the note so the cached row is refreshed
       // (cover/content) — boosting again heals a stale copy from EVERY boost
       // path, not just the interact page.
       ActivityPubService.resolveRemoteNote(note)
-        .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
+        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
         .catch(() => { /* best-effort */ });
-    } else {
-      ActivityPubService.unmarkBoosted(site.slug, note);
     }
   }
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 02fb7ce9e41008ada5f1e2fa8289da2cc7ad7687)
+++ src/services/ActivityPubService.js	(revision c010b423494784ff556a97a64ba69c3c733ffe0e)
@@ -2623,6 +2623,8 @@
         const kind = type === 'Announce' ? 'boost' : 'like';
         await sendInteraction(site, kind, objUri, authorUri);
-        setMyReaction(site.slug, targetUri, kind, true);
-        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
+        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
+        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
+        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
+        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
         // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
         // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
@@ -2634,5 +2636,4 @@
         // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
         // terecht een no-op.
-        if (type === 'Like') { try { markLiked(site.slug, objUri); } catch { /* non-fatal */ } }
         return { status: 202, url: objUri };
       }
@@ -2683,7 +2684,6 @@
           const objUri = (note && note.object_uri) || innerTarget;
           await sendInteraction(site, kind, objUri, note && note.actor_uri);
-          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
-          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
-          if (innerType === 'Like') { try { unmarkLiked(site.slug, objUri); } catch { /* non-fatal */ } }
+          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
+          catch { /* non-fatal */ }
           return { status: 202, url: objUri };
         }
@@ -3411,4 +3411,34 @@
   try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
 }
+/**
+ * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
+ * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
+ * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
+ * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
+ * dat leverde de shaer:liked-bug op (04aca12).
+ *
+ * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
+ * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
+ * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
+ * boost nergens -- ook niet in de Cirkel.
+ *
+ * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
+ * worden: de tussentabel op de URI die de client stuurde, de vlag op de
+ * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
+ * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
+ * werk voor fase 2, mét datamigratie.
+ */
+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;
+  if (kind === 'boost') {
+    if (!on) unmarkBoosted(slug, flagUri);
+    else if (opts.note) upsertBoostedNote(slug, opts.note);
+    else markBoosted(slug, flagUri);
+  } else if (on) markLiked(slug, flagUri);
+  else unmarkLiked(slug, flagUri);
+}
+
 export function getTimelineReaction(slug, noteId) {
   try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
@@ -4779,5 +4809,5 @@
   gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
-  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
+  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, setReaction, getTimelineReaction, 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 02fb7ce9e41008ada5f1e2fa8289da2cc7ad7687)
+++ test/reactions-characterization.test.js	(revision c010b423494784ff556a97a64ba69c3c733ffe0e)
@@ -9,7 +9,13 @@
 // eerst is vastgeschreven.
 //
-// LET OP bij het opruimen: de tests onder "de twee bronnen lopen uiteen" horen
-// te GAAN FALEN zodra fase 1 (één setReaction) klaar is. Dat is geen regressie
-// maar het doel; werk ze dan bewust bij in plaats van ze te laten verdwijnen.
+// LET OP: de tests onder "de twee bronnen lopen uiteen" roepen de PRIMITIEVEN
+// aan (markLiked, setMyReaction). Die blijven bewust gescheiden -- het
+// samenvoegen zit in setReaction, dus in de aanroepers, niet in de primitieven.
+// Deze tests blijven daarom groen na fase 1 en beschrijven dan nog steeds iets
+// waars: wie markLiked los aanroept, raakt de tussentabel niet. Dat is precies
+// waarom setReaction bestaat en waarom die primitieven op termijn intern moeten
+// worden.
+//
+// De invariant die fase 1 wél bewaakt staat onderaan, bij setReaction.
 //
 // Wat hier NIET in kan: de routes zelf (die vragen HTTP + sessie) en het
@@ -115,6 +121,8 @@
 
 // ── De twee bronnen lopen uiteen ─────────────────────────────────────────
-// DEZE TESTS HOREN TE FALEN NA FASE 1. Ze leggen de huidige splitsing vast,
-// zodat het samenvoegen een zichtbare gebeurtenis is en geen stille.
+// De primitieven raken elkaar niet, en dat blijft na fase 1 zo: het
+// samenvoegen zit in setReaction. Deze drie leggen vast waarom die functie
+// moet bestaan, en waarom markLiked en broers uiteindelijk intern horen te
+// worden -- zolang ze los aanroepbaar zijn, kan een aanroeper de helft doen.
 
 test('HUIDIG GEDRAG: de tijdlijn-route vult de tussentabel niet', () => {
@@ -123,5 +131,5 @@
   assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
   assert.equal(AP.getMyReactions('me', u).liked, false,
-    'NA FASE 1 hoort dit true te zijn: pas deze test dan bewust aan');
+    'de primitief raakt de tussentabel niet -- daarvoor is setReaction');
 });
 
@@ -131,5 +139,5 @@
   assert.equal(AP.getMyReactions('me', u).liked, true, 'de tussentabel staat aan');
   assert.equal(AP.getTimelineReaction('me', u).liked, false,
-    'NA FASE 1 hoort dit true te zijn: pas deze test dan bewust aan');
+    'de primitief raakt de kolom niet -- daarvoor is setReaction');
 });
 
@@ -145,5 +153,5 @@
 });
 
-// ── Wat de invariant-test van fase 1 straks moet bewaken ─────────────────
+// ── De scheefheid, meetbaar (fase 0 uit shaer-9e9 als test) ─────────────
 
 test('meetbaar: hoeveel rijen hebben een vlag zonder tegenhanger', () => {
@@ -157,2 +165,49 @@
   assert.ok(scheef > 0, 'vandaag lopen ze uiteen; na fase 1 hoort deze assert omgedraaid te worden naar === 0');
 });
+
+// ── De invariant van fase 1: setReaction houdt de bronnen gelijk ─────────
+
+test('setReaction: een like landt in BEIDE bronnen', () => {
+  const u = uri('sr1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  assert.equal(AP.getMyReactions('me', u).liked, true, 'tussentabel');
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'afgeleide vlag');
+  AP.setReaction('me', u, 'like', false);
+  assert.equal(AP.getMyReactions('me', u).liked, false);
+  assert.equal(AP.getTimelineReaction('me', u).liked, false);
+});
+
+test('setReaction: een boost met note trekt de post je tijdlijn in EN vult beide', () => {
+  // De valkuil uit het plan: zonder de note bestaat de rij niet en landt de
+  // vlag nergens, dus zou de boost onvindbaar zijn.
+  const u = uri('sr2');
+  AP.setReaction('me', u, 'boost', true, {
+    note: { object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo', content: '<p>x</p>', media: '[]' },
+  });
+  assert.equal(AP.getMyReactions('me', u).boosted, true, 'tussentabel');
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'afgeleide vlag');
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en zichtbaar in de Cirkel');
+  AP.setReaction('me', u, 'boost', false);
+  assert.equal(AP.getMyReactions('me', u).boosted, false);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
+});
+
+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.
+  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');
+});
+
+test('setReaction: rommelige invoer doet niets in plaats van iets halfs', () => {
+  AP.setReaction('me', uri('sr4'), 'sterretje', true);
+  assert.equal(AP.getMyReactions('me', uri('sr4')).liked, false);
+  AP.setReaction('', uri('sr5'), 'like', true);
+  AP.setReaction('me', '', 'like', true);
+  assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
+});
