Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 7842ca18b5f43af17b73058c3cd8879ef9421728)
+++ src/services/ActivityPubService.js	(revision 25f2e64684dce47025b6441af8051a5345dac6ca)
@@ -3192,5 +3192,27 @@
   return { ins: _insTl, list: _listTl, del: _delTl };
 }
-export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
+/**
+ * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
+ *
+ * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
+ * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
+ * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
+ * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
+ * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
+ *
+ * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
+ * heten en hangt het van de driver af welke wint. Eén extra query per pagina
+ * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
+ */
+export function getTimeline(slug, limit, offset) {
+  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
+  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
+  for (const r of rows) {
+    const x = reacties.get(r.id);
+    r.liked = !!(x && x.liked);
+    r.boosted = !!(x && x.boosted);
+  }
+  return rows;
+}
 
 /**
@@ -3384,8 +3406,13 @@
     if (!_cirkelPosts) _cirkelPosts = db.prepare(`
       SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
-             t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
+             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
+             (rb.target_uri IS NOT NULL) AS boosted
       FROM ap_timeline t
       LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
-      WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
+      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
+      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
+      -- deze join kan geen rijen verdubbelen.
+      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
+      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
       ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
       LIMIT ? OFFSET ?`);
@@ -3637,5 +3664,12 @@
 }
 export function boostedCount(slug) {
-  try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
+  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
+  // bevatten van iets dat er (nog) niet in staat.
+  try {
+    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
+      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
+      WHERE r.site_slug = ? AND r.kind = 'boost'`);
+    return _boostedCount.get(slug).n;
+  } catch { return 0; }
 }
 
Index: test/reactions-characterization.test.js
===================================================================
--- test/reactions-characterization.test.js	(revision 7842ca18b5f43af17b73058c3cd8879ef9421728)
+++ test/reactions-characterization.test.js	(revision 25f2e64684dce47025b6441af8051a5345dac6ca)
@@ -61,13 +61,18 @@
 });
 
-test('tijdlijn-route: markBoosted zet de kolom en de post verschijnt in de Cirkel', () => {
+test('tijdlijn-route: markBoosted zet de kolom, maar de Cirkel volgt de tussentabel', () => {
+  // BIJGEWERKT toen de weergave werd gelijkgetrokken. Vroeger trok een kale
+  // kolomvlag de post de Cirkel in; nu selecteert de Cirkel op de tussentabel,
+  // dus de primitief alleen is niet meer genoeg. Dat is het doel: tekenen en
+  // beslissen leunen op dezelfde bron.
   const u = uri('t2'); seedTimeline(u);
-  assert.equal(AP.boostedCount('me'), 0);
   AP.markBoosted('me', u);
-  assert.equal(AP.getTimelineReaction('me', u).boosted, true);
-  assert.equal(AP.boostedCount('me'), 1);
-  // De Cirkel is wat een gebruiker ziet: geboost = zichtbaar daar.
-  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'geboost hoort in de Cirkel te staan');
-  AP.unmarkBoosted('me', u);
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'de kolom staat aan');
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
+    'maar dat brengt hem niet in de Cirkel');
+  // Via het echte schrijfpad wel.
+  AP.setReaction('me', u, 'boost', true);
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'via setReaction wel');
+  AP.setReaction('me', u, 'boost', false);
   assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
 });
@@ -117,5 +122,11 @@
   assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 1, 'de rij hoort aangemaakt te worden');
   assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'en meteen als geboost gemarkeerd');
-  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en dus zichtbaar in de Cirkel');
+  // BIJGEWERKT: de Cirkel volgt sinds het gelijktrekken van de weergave de
+  // tussentabel, en die vult alleen setReaction. upsertBoostedNote blijft
+  // verantwoordelijk voor de RIJ -- zonder die insert heeft de reactie nergens
+  // een post om bij te horen.
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false, 'de rij alleen is niet genoeg');
+  AP.setMyReaction('me', u, 'boost', true);
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'met de tussentabel erbij wel');
 });
 
@@ -363,2 +374,41 @@
   assert.equal(AP.getReaction('me', u).liked, false, 'maar heeft niets geschreven');
 });
+
+// ── De weergave leest dezelfde bron als de knop ──────────────────────────
+
+test('getTimeline tekent liked/boosted uit de tussentabel, niet uit de kolom', () => {
+  // De Krant tekende zijn knoppen op ap_timeline.liked/boosted terwijl de
+  // toggle al uit getReaction besliste. Tekenen en beslissen leunden dus op
+  // verschillende bronnen -- ze waren het alleen eens zolang iets ze gelijk
+  // hield. Hier wordt de kolom expres tegengesteld gezet.
+  const u = uri('tl1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  db.prepare('UPDATE ap_timeline SET liked = 0, boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
+
+  const rij = AP.getTimeline('me', 50, 0).find((r) => r.id === u);
+  assert.equal(rij.liked, true, 'de tussentabel zegt geliket, dus de knop hoort aan');
+  assert.equal(rij.boosted, false, 'en de kolom die iets anders beweert telt niet');
+});
+
+test('de Cirkel selecteert op de tussentabel', () => {
+  const u = uri('tl2'); seedTimeline(u);
+  // Kolom aan, tussentabel leeg: hoort NIET in de Cirkel te staan.
+  db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
+    'een kale kolomvlag trekt niets meer de Cirkel in');
+  // En andersom: tussentabel aan, kolom uit.
+  db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?').run('me', u);
+  AP.setMyReaction('me', u, 'boost', true);
+  const post = AP.getCirkelPosts('me', 60, 0).find((p) => p.id === u);
+  assert.ok(post, 'de tussentabel bepaalt wat er in de Cirkel staat');
+  assert.ok(post.boosted, 'en het veld dat de view tekent komt daar ook vandaan');
+});
+
+test('boostedCount telt de tussentabel, en alleen wat in je tijdlijn staat', () => {
+  const inTl = uri('tl3'), buiten = 'https://elders.test/notes/qqq';
+  seedTimeline(inTl);
+  const voor = AP.boostedCount('me');
+  AP.setMyReaction('me', inTl, 'boost', true);
+  AP.setMyReaction('me', buiten, 'boost', true);
+  assert.equal(AP.boostedCount('me'), voor + 1, 'alleen de post die in je tijdlijn staat telt mee');
+});
