Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
+++ src/routes/activitypub.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
@@ -1411,4 +1411,21 @@
 });
 
+// De poorten op één vreemde note, voor de thread en voor de losse note:
+// beeld, muziek, video en emoji, per verzoek en buiten elke cache om. De
+// emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de
+// ingesloten actor, niet meer in een eigen emoji-kaart ernaast.
+function readerGates(site, isWard) {
+  const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
+  const images = gate('gate_images'), audio = gate('gate_music'), video = gate('gate_video');
+  const emoji = gate('gate_custom_emoji');
+  return (n) => ({
+    ...n,
+    attachment: AP.gateAttachments(n.attachment, { images, audio, video }),
+    tag: emoji ? n.tag : AP.stripEmojiTags(n.tag),
+    attributedTo: (!emoji && n.attributedTo && typeof n.attributedTo === 'object')
+      ? { ...n.attributedTo, tag: undefined } : n.attributedTo,
+  });
+}
+
 // ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
 //
@@ -1446,17 +1463,6 @@
   // poorten als de tijdlijn -- per verzoek, buiten de threadcache om.
   const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
-  const gate2 = (col) => Guardianship.wardGateAllowed(auth.site[col], isWard);
   const kring = threadsOpen ? { notes: uit.notes, hidden: 0 } : AP.filterThreadToCircle(auth.site.slug, uit.notes);
-  const imagesOk = gate2('gate_images'), musicOk = gate2('gate_music'), emojiOk = gate2('gate_custom_emoji');
-  const videoOk = gate2('gate_video');
-  uit.notes = kring.notes.map((n) => ({
-    ...n,
-    attachment: AP.gateAttachments(n.attachment, { images: imagesOk, audio: musicOk, video: videoOk }),
-    tag: emojiOk ? n.tag : AP.stripEmojiTags(n.tag),
-    // De emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de
-    // ingesloten actor, niet meer in een eigen emoji-kaart ernaast.
-    attributedTo: (!emojiOk && n.attributedTo && typeof n.attributedTo === 'object')
-      ? { ...n.attributedTo, tag: undefined } : n.attributedTo,
-  }));
+  uit.notes = kring.notes.map(readerGates(auth.site, isWard));
   uit.hidden = kring.hidden;
   // Liked/boosted per antwoord, BUITEN de cache om: de genormaliseerde notes
@@ -1481,4 +1487,31 @@
 });
 
+// ── Eén note, voor wie een gesprek naar boven volgt ────────────────
+//
+// De thread hierboven geeft wat onder een post hangt; dit geeft de post zelf.
+// Een lezer die een antwoord kreeg en de ouder niet, kan die ouder bij een
+// server in secure mode alleen ondertekend halen, en de sleutel staat hier.
+// Bearer-only en niets bewaren, net als de thread. Dezelfde weigering-of-
+// storing en dezelfde poorten; voor een ward met een dichte kring is een note
+// van buiten de kring er niet, en dat wordt gezegd in plaats van verzwegen.
+router.get('/ap/users/:slug/object', async (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const objectUri = String(req.query.uri || '');
+  if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'uri must be an https URI' });
+  const uit = await AP.getObject(auth.site.slug, objectUri);
+  if (!uit.found) {
+    const geweigerd = [401, 403, 404, 410].includes(uit.sourceStatus);
+    return res.status(geweigerd ? 404 : 502)
+      .json({ error: geweigerd ? 'not shared by source' : 'source unreachable', sourceStatus: uit.sourceStatus || undefined });
+  }
+  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
+  const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
+  if (!threadsOpen && !AP.filterThreadToCircle(auth.site.slug, [uit.note]).notes.length) {
+    return res.status(404).json({ error: 'outside the circle', 'shaer:hidden': 1 });
+  }
+  AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...readerGates(auth.site, isWard)(uit.note) }, 'private, no-store');
+});
+
 router.get('/ap/notes/:id/replies', (req, res) => {
   const base = baseUrl(req);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
+++ src/services/ActivityPubService.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
@@ -2496,81 +2496,20 @@
 
 /**
- * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
- *
- * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
- * kringfilter (de dichte stand van shaer:externalThreads) woont in
- * filterThreadToCircle en de beeld/muziek/emoji-poorten in
- * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
- * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
- * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
- * een blokkade is onzichtbaar, ook als getal.
+ * Eén vreemde note in de vorm die de C2S-lezer krijgt: geschoonde HTML, de
+ * byline ingesloten in attributedTo, alleen emoji-tags. Gedeeld door de thread
+ * (antwoorden) en getObject (één note), zodat die twee niet uit elkaar lopen.
+ *
+ * `parentFallback` is voor de thread: een antwoord in de replies-collectie van
+ * X dat zelf geen inReplyTo noemt, hangt aantoonbaar onder X. Voor één losse
+ * note is er zo'n bewijs niet, en dan blijft inReplyTo leeg.
  */
-export async function getThread(slug, objectUri) {
-  const key = `${slug}|${objectUri}`;
-  const hit = threadViewCache.get(key);
-  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
-
-  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
-  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
-  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
-  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
-  let sourceStatus = 0;
-  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
-  const note = await get(objectUri);
-  const repliesRef = note && note.replies;
-  let coll = null;
-  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
-  else if (repliesRef && typeof repliesRef === 'object') {
-    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
-      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
-  }
-  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
-  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
-  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
-  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
-  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
-  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
-  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
-  let items = [];
-  let node = coll;
-  if (node && node.first && !collectionItems(node).length) {
-    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
-  }
-  let pages = 0;
-  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
-    items.push(...collectionItems(node));
-    if (!node.next) break;
-    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
-  }
-  items = items.slice(0, THREAD_VIEW_LIMIT);
-
-  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
-  // afwachten is een halve minuut kijken naar een spinner.
-  const objs = await Promise.all(items.map(async (it) => {
-    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
-    return (o && o.id && o.attributedTo) ? o : null;
-  }));
-
-  const kept = [];
-  for (const o of objs) {
-    if (!o) continue;
-    const actorUri = actorUriOf(o.attributedTo);
-    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
-    kept.push({ o, actorUri });
-  }
-
-  // Bylines: één fetch per unieke auteur, niet één per antwoord.
-  const authors = new Map();
-  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
-    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
-  }));
-
-  const notes = kept.map(({ o, actorUri }) => ({
+function readerNote(o, actorUri, authorDoc, parentFallback = null) {
+  return {
     id: o.id,
     type: 'Note',
     // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar
     // elke AP-lezer hem zoekt, en niet in een eigen property ernaast.
-    attributedTo: actorObject(actorUri, actorInfo(authors.get(actorUri), actorUri)),
-    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
+    attributedTo: actorObject(actorUri, actorInfo(authorDoc, actorUri)),
+    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || parentFallback || undefined,
     content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
     url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
@@ -2597,5 +2536,80 @@
       return out.length ? out : undefined;
     })(),
-  })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
+  };
+}
+
+/**
+ * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
+ *
+ * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
+ * kringfilter (de dichte stand van shaer:externalThreads) woont in
+ * filterThreadToCircle en de beeld/muziek/emoji-poorten in
+ * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
+ * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
+ * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
+ * een blokkade is onzichtbaar, ook als getal.
+ */
+export async function getThread(slug, objectUri) {
+  const key = `${slug}|${objectUri}`;
+  const hit = threadViewCache.get(key);
+  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
+
+  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
+  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
+  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
+  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
+  let sourceStatus = 0;
+  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
+  const note = await get(objectUri);
+  const repliesRef = note && note.replies;
+  let coll = null;
+  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
+  else if (repliesRef && typeof repliesRef === 'object') {
+    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
+      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
+  }
+  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
+  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
+  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
+  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
+  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
+  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
+  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
+  let items = [];
+  let node = coll;
+  if (node && node.first && !collectionItems(node).length) {
+    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
+  }
+  let pages = 0;
+  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
+    items.push(...collectionItems(node));
+    if (!node.next) break;
+    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
+  }
+  items = items.slice(0, THREAD_VIEW_LIMIT);
+
+  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
+  // afwachten is een halve minuut kijken naar een spinner.
+  const objs = await Promise.all(items.map(async (it) => {
+    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
+    return (o && o.id && o.attributedTo) ? o : null;
+  }));
+
+  const kept = [];
+  for (const o of objs) {
+    if (!o) continue;
+    const actorUri = actorUriOf(o.attributedTo);
+    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
+    kept.push({ o, actorUri });
+  }
+
+  // Bylines: één fetch per unieke auteur, niet één per antwoord.
+  const authors = new Map();
+  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
+    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
+  }));
+
+  const notes = kept.map(({ o, actorUri }) => readerNote(o, actorUri, authors.get(actorUri), objectUri))
+    .sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
 
   const out = { notes, found: !!note, sourceStatus };
@@ -2606,4 +2620,27 @@
   }
   return out;
+}
+
+/**
+ * Eén note, ondertekend opgehaald, in dezelfde vorm als een thread-antwoord.
+ *
+ * De thread hierboven geeft alleen wat ONDER een post hangt. Wie een gesprek
+ * naar boven wil volgen -- een antwoord kwam binnen, de ouder nooit -- kan die
+ * ouder zonder de sleutel die hier woont niet halen bij een server in secure
+ * mode. Niets wordt bewaard, net als bij de thread.
+ *
+ * inReplyTo staat er alleen als de bron hem gaf: een verzonnen ouder is erger
+ * dan geen. Een geblokkeerde auteur maakt de note onvindbaar, met dezelfde
+ * status als een bron die nee zegt -- een blokkade hoort onzichtbaar te zijn.
+ */
+export async function getObject(slug, objectUri) {
+  let sourceStatus = 0;
+  const o = localNoteObject(objectUri, slug)
+    || await signedGetJson(slug, objectUri, (st) => { sourceStatus = st; });
+  if (!o || !o.id || !o.attributedTo) return { note: null, found: false, sourceStatus };
+  const actorUri = actorUriOf(o.attributedTo);
+  if (!actorUri || isBlockedAny(actorUri)) return { note: null, found: false, sourceStatus: 404 };
+  const author = localActorObject(actorUri) || await signedGetJson(slug, actorUri).catch(() => null);
+  return { note: readerNote(o, actorUri, author), found: true, sourceStatus };
 }
 
@@ -4346,5 +4383,5 @@
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
-  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
+  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, getObject, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
   noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
