Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 094f7d08b6d73938da056dc164a0ca363c60a269)
+++ src/routes/activitypub.js	(revision 73a10c23efd11af6b4f5da8a0187b68d003df265)
@@ -82,13 +82,27 @@
   const site = publicSite(req.params.slug);
   if (!site) return res.status(404).end();
-  // Authorized fetch (FEP-633c §5.3 note): a committed guardian doing a SIGNED
-  // GET may read the ward's fan-only history too, without appearing as a
-  // follower. Unsigned / non-guardian callers get the public collection only.
-  let asGuardian = false;
-  if (req.headers['signature']) {
+  // Authorized fetch (30-7): who is asking decides what they see.
+  //  - the owner's own app (bearer) and a verified accepted follower or
+  //    guardian get the friends-only history too, so a NEW friend's backfill
+  //    brings the past along (Robins besluit: vrienden krijgen de
+  //    geschiedenis mee);
+  //  - a verified caller this instance BLOCKS gets an EMPTY collection, not
+  //    even the public set: a block is a closed door, and a signed fetch is
+  //    the caller knocking with their name on it;
+  //  - everyone else gets the public collection, exactly as before.
+  const bearer = OAuth.verifyBearer(req.headers.authorization);
+  let verifiedActor = null;
+  if (!bearer && req.headers['signature']) {
     const verified = await AP.verifyRequest(req).catch(() => null);
-    asGuardian = !!(verified && AP.isWardGuardian(req.params.slug, verified.id));
-  }
-  const fanClause = asGuardian ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
+    verifiedActor = verified && verified.id;
+  }
+  const audience = AP.outboxAudience(req.params.slug, {
+    bearerSlug: bearer ? bearer.site.slug : null,
+    verifiedActor,
+  });
+  if (audience === 'blocked') {
+    return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, []), 'private, no-store');
+  }
+  const fanClause = audience === 'friend' ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
   const posts = db.prepare(
     `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
@@ -96,5 +110,5 @@
      ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
   ).all(site.id);
-  AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), asGuardian ? 'private, no-store' : undefined);
+  AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), audience === 'friend' ? 'private, no-store' : undefined);
 });
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 094f7d08b6d73938da056dc164a0ca363c60a269)
+++ src/services/ActivityPubService.js	(revision 73a10c23efd11af6b4f5da8a0187b68d003df265)
@@ -1854,4 +1854,9 @@
     if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
     console.log('[AP] follow accepted', actorUri);
+    // The moment a friendship exists is the moment the history comes along
+    // (Robins besluit, 30-7): delivery cannot reach into the past, so the
+    // fresh follower pulls the outbox, signed, and the other side now serves
+    // the friends-only posts too.
+    if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
     return 202;
   }
@@ -3238,4 +3243,30 @@
 
 // A generic SSRF-safe AP GET (collections / pages).
+/**
+ * A signed GET as one of our local actors (friends-history, 30-7): the remote
+ * server can then recognise the caller and serve what THAT caller may see,
+ * exactly like the guardian's authorized fetch. The signature covers
+ * (request-target) host date, the set verifyRequest checks.
+ */
+async function signedGetJson(slug, url) {
+  try {
+    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+    if (!base || !slug) return apGetJson(url);
+    const me = actorId(base, slug);
+    const keys = getOrCreateKeys(slug);
+    const u = new URL(url);
+    const date = new Date().toUTCString();
+    const target = `${u.pathname}${u.search || ''}`;
+    const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
+    const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
+    const sig = `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`;
+    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json', Date: date, Signature: sig } });
+    if (!r.ok) return null;
+    const len = Number(r.headers.get('content-length') || 0);
+    if (len > 3_000_000) return null;
+    return await r.json();
+  } catch { return null; }
+}
+
 async function apGetJson(url) {
   try {
@@ -3256,8 +3287,12 @@
     const actor = await fetchActor(actorUri);
     if (!actor || !actor.outbox) return 0;
-    let page = await apGetJson(typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
+    // Signed as the follower (30-7): the serving side recognises an accepted
+    // friend and hands the friends-only history along; an anonymous GET only
+    // ever sees the public set. A server that ignores the signature behaves
+    // exactly as before.
+    let page = await signedGetJson(slug, typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
     let items = (page && (page.orderedItems || page.items)) || [];
     if (!items.length && page && page.first) {
-      page = await apGetJson(typeof page.first === 'string' ? page.first : page.first.id);
+      page = await signedGetJson(slug, typeof page.first === 'string' ? page.first : page.first.id);
       items = (page && (page.orderedItems || page.items)) || [];
     }
@@ -3548,4 +3583,24 @@
 
 // FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
+/**
+ * Who is reading this outbox, and what may they see (30-7)?
+ *  - 'blocked': a verified caller this instance blocks. They get an EMPTY
+ *    collection, not even the public set (Robins eis): a block is a closed
+ *    door, and a signed fetch is the caller knocking with their name on it.
+ *  - 'friend': the owner (bearer) or a verified accepted follower or
+ *    guardian: the fan-only history rides along.
+ *  - 'public': everyone else: the public set.
+ */
+export function outboxAudience(slug, { bearerSlug = null, verifiedActor = null } = {}) {
+  if (bearerSlug && bearerSlug === slug) return 'friend';
+  if (!verifiedActor) return 'public';
+  if (isBlockedAny(verifiedActor)) return 'blocked';
+  try {
+    if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
+  } catch { /* table absent on fresh init */ }
+  if (isWardGuardian(slug, verifiedActor)) return 'friend';
+  return 'public';
+}
+
 // guardian of the local ward `wardSlug` — so a signed GET from it may read the
 // ward's non-public history without the guardian appearing as a follower.
@@ -4044,5 +4099,5 @@
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
-  acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
+  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
