Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/config/database.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -1058,4 +1058,17 @@
   // opnieuw op te halen, en een verzonnen ouder is erger dan geen.
   ensureColumn('ap_mentions', 'in_reply_to', 'TEXT');
+  // Zachte groepen (shaer-mbji, src/services/conversation-key.js). Een gesprek
+  // is de SET deelnemers; die werd bij een binnenkomende note nergens bewaard,
+  // dus een bericht van Anna aan jou en Ben wist niet meer dat Ben erbij was.
+  //
+  // `conv` is de sleutel, één keer berekend bij het schrijven: elke lezing
+  // hoeft hem dan alleen te lezen, en een sleutel die bij elke query opnieuw
+  // uit JSON gebouwd wordt is een plek om het verschillend te doen. NULL op
+  // oude rijen, en dan valt de lezing terug op wat hij altijd deed.
+  ensureColumn('ap_mentions', 'conv', 'TEXT');
+  ensureColumn('ap_mentions', 'participants', 'TEXT');  // JSON: de anderen, zonder mij
+  ensureColumn('ap_mentions', 'context', 'TEXT');       // FEP-7888
+  ensureColumn('ap_outbox', 'conv', 'TEXT');
+  ensureColumn('ap_outbox', 'context', 'TEXT');
   ensureColumn('ap_interactions', 'media_json', 'TEXT');
   ensureColumn('ap_interactions', 'quote_json', 'TEXT');
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/routes/activitypub.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -20,4 +20,5 @@
 import OAuth from '../services/OAuthService.js';
 import * as Guardianship from '../services/guardianship/index.js';
+import * as ConversationKey from '../services/conversation-key.js';
 import * as Migration from '../services/MigrationService.js';
 import { getPrimarySite } from '../middleware/site.js';
@@ -626,4 +627,15 @@
     }
   }
+  // WELK GESPREK, EN MET WIE (shaer-mbji). De client kon dat afleiden uit de
+  // adressering van de note -- maar een binnenkomer draagt die niet mee in zijn
+  // kaart, dus een bericht van Anna aan jou en Ben las als iets van Anna alleen.
+  // De server weet het: de sleutel staat in de kop. Twee termen, zodat een
+  // client die alleen de deelnemers wil niet een sleutel hoeft te ontleden.
+  for (const { head, item } of pairs) {
+    const obj = item && item.object;
+    if (!obj || !head.other) continue;
+    obj['shaer:conversation'] = head.other;
+    obj['shaer:participants'] = ConversationKey.participantsOfKey(head.other);
+  }
   return pairs;
 }
@@ -673,6 +685,14 @@
   const auth = OAuth.verifyBearer(req.headers.authorization);
   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const other = String(req.query.with || '');
-  if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
+  // EÉN OF MEER `with` (shaer-mbji). Eén is het gesprek met die persoon, zoals
+  // altijd; meer is de zachte groep van precies die mensen. De sleutel komt
+  // uit dezelfde functie als bij het schrijven, dus {Anna, Ben} en {Ben, Anna}
+  // vragen hetzelfde gesprek op.
+  const withs = [].concat(req.query.with || []).map(String);
+  if (!withs.length || !withs.every((u) => /^https?:\/\//i.test(u))) {
+    return res.status(400).json({ error: 'with must be one or more actor URIs' });
+  }
+  const other = ConversationKey.conversationKey(withs);
+  if (!other) return res.status(400).json({ error: 'with must be one or more actor URIs' });
   const page = AP.conversationHistory(auth.site.slug, other, {
     before: req.query.before ? String(req.query.before) : null,
@@ -684,5 +704,9 @@
   // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
   const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
-  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
+  // Elke deelnemer een eigen `with`, zodat de next-link hetzelfde gesprek
+  // opvraagt. De sleutel zelf er kaal in zetten zou bij een groep een spatie in
+  // de URL leggen, en wie die als één adres leest vraagt iets anders op.
+  const withQuery = ConversationKey.participantsOfKey(other).map((u) => `with=${encodeURIComponent(u)}`).join('&');
+  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?${withQuery}`;
   AP.sendAP(res, {
     '@context': AP.AP_CONTEXT,
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);
Index: test/soft-groups.test.js
===================================================================
--- test/soft-groups.test.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
+++ test/soft-groups.test.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
@@ -0,0 +1,116 @@
+// Zachte groepen in Berichten (shaer-mbji): een gesprek is de SET deelnemers,
+// niet één tegenpartij.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const K = await import('../src/services/conversation-key.js');
+const T = await import('../src/services/ap-timeline.js');
+
+const ME = 'https://test.example/ap/users/me';
+const ANNA = 'https://a.test/users/anna';
+const BEN = 'https://b.test/users/ben';
+const CARLA = 'https://c.test/users/carla';
+const PUB = 'https://www.w3.org/ns/activitystreams#Public';
+
+// ── De sleutel ────────────────────────────────────────────────────
+
+test('één ander is zijn kale URI: bestaande gesprekken veranderen niet', () => {
+  assert.equal(K.conversationKey([ANNA]), ANNA);
+});
+
+test('een groep is gesorteerd, dus {Anna, Ben} en {Ben, Anna} zijn één gesprek', () => {
+  assert.equal(K.conversationKey([BEN, ANNA]), K.conversationKey([ANNA, BEN]));
+  assert.equal(K.conversationKey([ANNA, BEN]), `${ANNA} ${BEN}`);
+  assert.equal(K.conversationKey([ANNA, ANNA, BEN]), `${ANNA} ${BEN}`, 'dubbelen tellen een keer');
+});
+
+test('een publiek of een volgerslijst is geen deelnemer', () => {
+  assert.equal(K.conversationKey([ANNA, PUB, `${ANNA}/followers`]), ANNA);
+});
+
+test('de sleutel valt terug in zijn deelnemers', () => {
+  assert.deepEqual(K.participantsOfKey(`${ANNA} ${BEN}`), [ANNA, BEN]);
+  assert.deepEqual(K.participantsOfKey(ANNA), [ANNA]);
+  assert.equal(K.isGroupKey(`${ANNA} ${BEN}`), true);
+  assert.equal(K.isGroupKey(ANNA), false);
+});
+
+test('de deelnemers van een binnenkomer: afzender plus geadresseerden, zonder mij', () => {
+  const note = { to: [ME, BEN], cc: [PUB] };
+  assert.deepEqual(K.participantsOf(note, ANNA, ME), [ANNA, BEN].sort());
+  // Een gewoon één-op-één-bericht: alleen de afzender blijft over.
+  assert.deepEqual(K.participantsOf({ to: [ME] }, ANNA, ME), [ANNA]);
+  // AS2 staat ook objecten toe in to/cc.
+  assert.deepEqual(K.participantsOf({ to: [{ id: ME }, { id: BEN }] }, ANNA, ME), [ANNA, BEN].sort());
+});
+
+test('context (FEP-7888) is een string of een object met een id', () => {
+  assert.equal(K.contextOf({ context: 'https://a.test/ctx/1' }), 'https://a.test/ctx/1');
+  assert.equal(K.contextOf({ context: { id: 'https://a.test/ctx/2' } }), 'https://a.test/ctx/2');
+  assert.equal(K.contextOf({ context: 'javascript:alert(1)' }), null, 'alleen http(s)');
+  assert.equal(K.contextOf({}), null);
+});
+
+// ── De lezing ─────────────────────────────────────────────────────
+
+let n = 0;
+function binnen(van, conv, stamp) {
+  n += 1;
+  db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, content, published, conv, participants)
+              VALUES ('me', ?, ?, '<p>x</p>', ?, ?, ?)`)
+    .run(`${van}/notes/${n}`, van, stamp, conv, conv ? JSON.stringify(K.participantsOfKey(conv)) : null);
+  return `${van}/notes/${n}`;
+}
+function uit(ontvangers, { conv = K.conversationKey(ontvangers), stamp }) {
+  n += 1;
+  const id = `out-${n}`;
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, content, visibility, to_actor, to_actors, conv, created_at)
+              VALUES (?, 'me', '', '<p>x</p>', 'direct', ?, ?, ?, ?)`)
+    .run(id, ontvangers[0], JSON.stringify(ontvangers), conv, stamp);
+  return id;
+}
+
+test('een bericht aan Anna en Ben is één gesprek, niet twee', () => {
+  uit([ANNA, BEN], { stamp: '2026-10-01 10:00:00' });
+  binnen(ANNA, K.conversationKey([ANNA, BEN]), '2026-10-01T10:05:00Z');
+  binnen(ANNA, ANNA, '2026-10-01T09:00:00Z');   // het één-op-één-gesprek bestaat ernaast
+
+  const koppen = T.conversationHeads('me').map((h) => h.other);
+  const groep = K.conversationKey([ANNA, BEN]);
+  assert.ok(koppen.includes(groep), 'de groep heeft een kop');
+  assert.ok(koppen.includes(ANNA), 'het gesprek met alleen Anna blijft bestaan');
+  assert.ok(!koppen.includes(BEN), 'Ben heeft geen los gesprek gekregen van een groepsbericht');
+
+  const draad = T.conversationHistory('me', groep).rows;
+  assert.equal(draad.length, 2, 'mijn bericht en Anna\'s antwoord staan in dezelfde draad');
+  assert.deepEqual(new Set(draad.map((r) => r.direction)), new Set(['in', 'out']));
+});
+
+test('oude uitgaande rijen zonder sleutel lezen zoals ze altijd lazen', () => {
+  // Geen conv: per ontvanger, zoals voor deze wijziging. Geen migratie die de
+  // geschiedenis herschikt.
+  uit([CARLA, BEN], { conv: null, stamp: '2026-09-01 10:00:00' });
+  const koppen = T.conversationHeads('me').map((h) => h.other);
+  assert.ok(koppen.includes(CARLA));
+  assert.ok(koppen.includes(BEN));
+  assert.ok(!koppen.includes(K.conversationKey([BEN, CARLA])), 'een oude rij wordt niet alsnog een groep');
+});
+
+test('ongelezen en gelezen gaan per gesprek, ook voor een groep', () => {
+  const groep = K.conversationKey([ANNA, CARLA]);
+  const eerste = binnen(ANNA, groep, '2026-10-02T10:00:00Z');
+  binnen(CARLA, groep, '2026-10-02T10:01:00Z');
+
+  assert.equal(T.unreadPerConversation('me').get(groep).n, 2);
+  T.markRead('me', eerste);
+  assert.equal(T.unreadPerConversation('me').get(groep).n, 1, 'gelezen tot en met het eerste');
+  // Het één-op-één-gesprek met Anna merkt daar niets van.
+  const losAnna = T.unreadPerConversation('me').get(ANNA);
+  assert.ok(!losAnna || losAnna.n >= 1);
+});
