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,
Index: test/thread.test.js
===================================================================
--- test/thread.test.js	(revision 6e9bc706a339b9972bf09b6a20ff63d6e3637471)
+++ test/thread.test.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
@@ -240,2 +240,99 @@
   assert.equal(broken.body.error, 'source unreachable');
 });
+
+// ── Eén note, naar boven (de object-route) ───────────────────────────
+//
+// De thread geeft alleen wat onder een post hangt. Een lezer die een antwoord
+// kreeg en de ouder niet, vraagt de ouder hier. Wat hier stil kan breken: een
+// ouder die verzonnen wordt (de thread-fallback die meelekt), een geblokkeerde
+// die toch zichtbaar wordt, en een route zonder bearer-check.
+test('getObject geeft de note zelf, met alleen de ouder die de bron noemde', async (t) => {
+  const vorige = globalThis.fetch;
+  t.after(() => { globalThis.fetch = vorige; });
+  const KIND_REPLY = 'https://203.0.113.60/notes/antwoord';
+  const WORTEL = 'https://203.0.113.60/notes/wortel';
+  globalThis.fetch = async (url) => {
+    const u = String(url);
+    const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+    if (u === KIND_REPLY) return json({ id: KIND_REPLY, type: 'Note', attributedTo: TANTE, inReplyTo: WORTEL, content: '<p>ja <script>x</script></p>', published: '2026-09-07T13:42:26Z' });
+    if (u === WORTEL) return json({ id: WORTEL, type: 'Note', attributedTo: TANTE, content: '<p>begin</p>' });
+    if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
+    return new Response('not found', { status: 404 });
+  };
+
+  const antwoord = await AP.getObject('kind', KIND_REPLY);
+  assert.equal(antwoord.found, true);
+  assert.equal(antwoord.note.inReplyTo, WORTEL, 'de ouder die de bron gaf');
+  assert.equal(antwoord.note.attributedTo.name, 'tante');
+  assert.ok(!antwoord.note.content.includes('<script'), 'geschoond');
+
+  const wortel = await AP.getObject('kind', WORTEL);
+  assert.equal(wortel.found, true);
+  assert.equal(wortel.note.inReplyTo, undefined, 'geen ouder verzonnen voor een wortel');
+});
+
+test('getObject: een geblokkeerde auteur is er niet, een weigering is een weigering', async (t) => {
+  const vorige = globalThis.fetch;
+  t.after(() => { globalThis.fetch = vorige; });
+  const VAN_NAAR = 'https://203.0.113.40/notes/van-naar';
+  globalThis.fetch = async (url) => (String(url) === VAN_NAAR
+    ? new Response(JSON.stringify({ id: VAN_NAAR, type: 'Note', attributedTo: GEBLOKT, content: 'x' }), { status: 200 })
+    : new Response('nope', { status: 410 }));
+
+  const geblokt = await AP.getObject('kind', VAN_NAAR);
+  assert.equal(geblokt.found, false);
+  assert.equal(geblokt.note, null);
+
+  const weg = await AP.getObject('kind', 'https://203.0.113.61/notes/weg');
+  assert.equal(weg.found, false);
+  assert.equal(weg.sourceStatus, 410);
+});
+
+test('de object-route: alleen met de eigen bearer, en een weigering is 404', async (t) => {
+  const crypto = await import('crypto');
+  const express = (await import('express')).default;
+  const routes = (await import('../src/routes/activitypub.js')).default;
+
+  // Een eigen site zonder guardians, zodat de poorten van de tests hierboven
+  // (de ward met de dichte emoji-poort) hier niet meetellen.
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s-lezer', 'lezer', 'Lezer', 'u1');
+  const bearer = 'test-token-' + 'e'.repeat(24);
+  db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
+    .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'lezer', 'read write');
+
+  const app = express();
+  app.use(routes);
+  const server = app.listen(0);
+  t.after(() => server.close());
+  await new Promise((r) => server.once('listening', r));
+
+  const vorige = globalThis.fetch;
+  t.after(() => { globalThis.fetch = vorige; });
+  const OUDER = 'https://203.0.113.62/notes/ouder';
+  globalThis.fetch = async (url) => {
+    const u = String(url);
+    if (u === OUDER) return new Response(JSON.stringify({ id: OUDER, type: 'Note', attributedTo: VREEMDE, content: '<p>de ouder</p>', inReplyTo: BRON }), { status: 200 });
+    if (u === VREEMDE) return new Response(JSON.stringify(actorDoc(VREEMDE, 'vreemde')), { status: 200 });
+    return new Response('nope', { status: 404 });
+  };
+  const ask = async (slug, uri, auth = `Bearer ${bearer}`) => {
+    const r = await realFetch(
+      `http://127.0.0.1:${server.address().port}/ap/users/${slug}/object?uri=${encodeURIComponent(uri)}`,
+      { headers: auth ? { Authorization: auth } : {} });
+    return { status: r.status, body: r.status === 403 ? null : await r.json() };
+  };
+
+  assert.equal((await ask('lezer', OUDER, null)).status, 403, 'zonder bearer niets');
+  assert.equal((await ask('kind', OUDER)).status, 403, 'niet met de bearer van een ander');
+  assert.equal((await ask('lezer', 'http://203.0.113.62/notes/ouder')).status, 400, 'alleen https');
+
+  const ok = await ask('lezer', OUDER);
+  assert.equal(ok.status, 200);
+  assert.equal(ok.body.id, OUDER);
+  assert.equal(ok.body.inReplyTo, BRON);
+  assert.equal(ok.body.attributedTo.name, 'vreemde');
+
+  const dicht = await ask('lezer', 'https://203.0.113.63/notes/dicht');
+  assert.equal(dicht.status, 404);
+  assert.equal(dicht.body.error, 'not shared by source');
+});
