Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/services/ActivityPubService.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -549,4 +549,8 @@
       attributedTo: meR,
       inReplyTo: post.in_reply_to || undefined,
+      // FEP-7888 (shaer-mbji): het gesprek waar dit bij hoort, in AS2 dat
+      // andere servers ook lezen. Alleen op wat er een heeft -- oude rijen en
+      // gewone antwoorden blijven precies zoals ze waren.
+      context: post.context || undefined,
       content: post.content,
       // Reply language (rich replies): the AS2 language map next to `content`.
Index: src/services/ap-inbox.js
===================================================================
--- src/services/ap-inbox.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/services/ap-inbox.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -30,4 +30,5 @@
 import { fwStmts } from './ap-following.js';
 import * as HubInvite from './hub-invite.js';
+import * as ConversationKey from './conversation-key.js';
 
 /**
@@ -852,8 +853,22 @@
             // laten voor iedereen die de objectvorm stuurt.
             const ouder = safeUrl(typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || null;
-            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, in_reply_to, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
-                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
+            // WIE ER NOG MEER BIJ WAS (shaer-mbji). De afzender plus iedere
+            // persoon in to/cc, zonder ons. Zonder dit wist een bericht van
+            // Anna aan jou en Ben niet meer dat Ben erbij was, en kwam het
+            // alleen in het gesprek met Anna terecht.
+            //
+            // ALLEEN VOOR EEN DIRECT BERICHT. Deze tabel draagt ook publieke
+            // posts die ons noemen, en een openbare post aan jou en Ben is geen
+            // besloten gesprek met z'n drieën -- dat zou een groep verzinnen
+            // die niemand begon. Die blijft bij zijn afzender, zoals altijd.
+            const ander = noteVisibility(o) === 'direct'
+              ? ConversationKey.participantsOf(o, actorUri, actorId(base, slug))
+              : [actorUri];
+            const conv = ConversationKey.conversationKey(ander) || actorUri;
+            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, in_reply_to, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, conv, participants, context, created_at)
+                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
               .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, ouder, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
-                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
+                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o),
+                conv, JSON.stringify(ander), ConversationKey.contextOf(o));
             if (r.changes) {
               // The quote / link-preview card resolves out of band (a remote
Index: src/services/ap-timeline.js
===================================================================
--- src/services/ap-timeline.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/services/ap-timeline.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -248,9 +248,26 @@
  */
 
+// `other` is hier DE GESPREKSSLEUTEL (shaer-mbji, conversation-key.js), en niet
+// meer per se één persoon. Voor één ander is dat zijn URI, precies zoals
+// altijd; voor een groep de gesorteerde set. De naam van de kolom bleef, want
+// koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal -- en
+// die werken nu per gesprek in plaats van per persoon zonder dat er een regel
+// in hoefde te veranderen.
+//
+// Oude rijen hebben geen `conv` en lezen zoals ze altijd lazen: een binnenkomer
+// bij zijn afzender, een uitgaande bij elke ontvanger afzonderlijk. Geen
+// migratie die de geschiedenis herschikt; alleen wat nieuw is, groepeert.
 const CONVERSATION_UNION = `
-  SELECT m.actor_uri AS other, ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
+  SELECT COALESCE(NULLIF(m.conv, ''), m.actor_uri) AS other,
+         ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
          'in' AS direction, m.object_uri AS ref
     FROM ap_mentions m
    WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''
+  UNION ALL
+  SELECT o.conv AS other, ${STEMPEL('o.created_at')} AS stamp,
+         'out' AS direction, o.id AS ref
+    FROM ap_outbox o
+   WHERE o.site_slug = @slug AND o.visibility = 'direct'
+     AND o.conv IS NOT NULL AND o.conv <> ''
   UNION ALL
   SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,
@@ -259,4 +276,5 @@
     JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
    WHERE o.site_slug = @slug AND o.visibility = 'direct'
+     AND (o.conv IS NULL OR o.conv = '')
      AND j.value IS NOT NULL AND j.value <> ''`;
 
Index: src/services/conversation-key.js
===================================================================
--- src/services/conversation-key.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
+++ src/services/conversation-key.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -0,0 +1,89 @@
+/**
+ * Zachte groepen (shaer-mbji): een gesprek is de SET deelnemers, niet één
+ * tegenpartij.
+ *
+ * Een direct bericht aan Anna en Ben bestond al als groep op de draad --
+ * ap_outbox.to_actors droeg de hele set -- maar elke lezing hier sleutelde op
+ * één `other`. Een uitgaand bericht aan twee mensen viel daardoor in twee
+ * gesprekken uiteen, en een binnenkomend bericht van Anna aan jou en Ben kwam
+ * alleen bij Anna terecht: Ben viel eruit. Een gesprek met drie mensen bestond
+ * zo alleen als losse één-op-één-bubbels die elk een deel ervan droegen.
+ *
+ * DE SLEUTEL:
+ *
+ *   één ander   -> zijn actor-URI, kaal. Precies wat het altijd was, dus elk
+ *                  bestaand gesprek, elke leesmarkering en elke ?with=-URL
+ *                  blijft geldig. Niets te migreren.
+ *   meer        -> de URI's gesorteerd, gescheiden door één spatie. Een URI kan
+ *                  geen ongecodeerde spatie bevatten, dus de scheiding is
+ *                  ondubbelzinnig, en sorteren maakt {Anna, Ben} en {Ben, Anna}
+ *                  hetzelfde gesprek.
+ *
+ * Ikzelf zit nooit in de sleutel: ik ben in elk gesprek, dus mij noemen zegt
+ * niets en zou dezelfde groep twee sleutels geven, afhankelijk van wie hem
+ * begon.
+ *
+ * ZACHT BETEKENT: WIE GEADRESSEERD IS, ZIT ERIN. Geen aanmaakstap, geen
+ * eigenaar. Komt Carla er halverwege bij, dan is {Anna, Ben, Carla} een ander
+ * gesprek dan {Anna, Ben} -- en dat klopt, want een antwoord daar bereikt
+ * Carla en een antwoord in de oude bubbel niet. `context` (FEP-7888) reist mee
+ * en wordt bewaard, maar voegt hier geen bubbels samen: een bubbel die Carla
+ * toont terwijl je antwoord haar niet bereikt, zou liegen over wie meeleest.
+ *
+ * Puur, zonder afhankelijkheden: de inbox, de bezorging en de lezing gebruiken
+ * allemaal dezelfde sleutel, en dat kan alleen als hij op één plek staat.
+ */
+
+const PUBLIC = new Set([
+  'https://www.w3.org/ns/activitystreams#Public',
+  'as:Public',
+  'Public',
+]);
+
+/** Is dit een persoon, en niet een publiek of een volgerslijst? */
+export function isPersonalAddress(uri) {
+  if (typeof uri !== 'string' || !/^https?:\/\//i.test(uri)) return false;
+  if (PUBLIC.has(uri)) return false;
+  return !/\/followers\/?$/.test(uri);
+}
+
+const lijst = (v) => (Array.isArray(v) ? v : (v ? [v] : []))
+  .map((x) => (typeof x === 'string' ? x : (x && typeof x === 'object' ? x.id : null)))
+  .filter((x) => typeof x === 'string');
+
+/**
+ * De andere deelnemers van een binnenkomende note: de afzender plus iedere
+ * persoon in to/cc, zonder mij. Gesorteerd en ontdubbeld.
+ */
+export function participantsOf(note, author, me) {
+  const set = new Set();
+  if (isPersonalAddress(author)) set.add(author);
+  for (const u of [...lijst(note && note.to), ...lijst(note && note.cc)]) {
+    if (isPersonalAddress(u)) set.add(u);
+  }
+  set.delete(me);
+  return [...set].sort();
+}
+
+/** De gesprekssleutel van een set andere deelnemers. */
+export function conversationKey(others) {
+  const set = [...new Set((others || []).filter(isPersonalAddress))].sort();
+  return set.join(' ');
+}
+
+/** De deelnemers achter een sleutel. Een kale URI is een gesprek met één. */
+export function participantsOfKey(key) {
+  return String(key || '').split(' ').filter(isPersonalAddress);
+}
+
+/** Een groep: meer dan één ander. */
+export function isGroupKey(key) {
+  return participantsOfKey(key).length > 1;
+}
+
+/** De context van een note (FEP-7888): een string of een object met een id. */
+export function contextOf(note) {
+  const c = note && note.context;
+  const id = typeof c === 'string' ? c : (c && typeof c === 'object' ? c.id : null);
+  return typeof id === 'string' && /^https?:\/\//i.test(id) ? id : null;
+}
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/services/guardianship/delivery.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -14,4 +14,5 @@
 import db, { NU_ISO } from '../../config/database.js';
 import { carriesGuardians } from './context.js';
+import * as ConversationKey from '../conversation-key.js';
 
 const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
@@ -33,4 +34,26 @@
   if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
   return 'direct';
+}
+
+/**
+ * De context (FEP-7888) van het bericht waarop geantwoord wordt, als we die
+ * kennen: uit wat binnenkwam of uit wat we zelf stuurden. Een antwoord hoort in
+ * het gesprek van zijn ouder, en niet in een vers gesprek ernaast.
+ */
+function contextFor(slug, inReplyTo) {
+  if (!inReplyTo) return null;
+  try {
+    const binnen = db.prepare('SELECT context FROM ap_mentions WHERE slug = ? AND object_uri = ? AND context IS NOT NULL')
+      .get(slug, inReplyTo);
+    if (binnen && binnen.context) return binnen.context;
+    // Een eigen note heeft als uri .../ap/notes/<id>; de id is de outbox-sleutel.
+    const eigen = /\/ap\/notes\/([^/?#]+)$/.exec(String(inReplyTo));
+    if (eigen) {
+      const rij = db.prepare('SELECT context FROM ap_outbox WHERE site_slug = ? AND id = ? AND context IS NOT NULL')
+        .get(slug, eigen[1]);
+      if (rij && rij.context) return rij.context;
+    }
+  } catch { /* geen context is ook een antwoord */ }
+  return null;
 }
 
@@ -144,7 +167,14 @@
     .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
   const id = crypto.randomUUID();
-  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
-              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
-    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
+  // Het gesprek waar dit bij hoort (shaer-mbji): de set ontvangers. Aan Anna en
+  // Ben samen is één gesprek, en niet twee die elk de helft dragen.
+  const conv = ConversationKey.conversationKey(resolved.map((r) => r.uri));
+  // FEP-7888: het gesprek een naam geven die andere servers ook lezen. Is dit
+  // een antwoord op iets met een context, dan hoort het in diezelfde; anders
+  // begint hier een nieuwe.
+  const context = contextFor(site.slug, inReplyTo) || `${base}/ap/contexts/${crypto.randomUUID()}`;
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, conv, context, created_at)
+              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
+    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null, conv, context);
   const row = getOutboxRow(id);
   const note = buildReplyNote(base, site, row);
