Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision af5b79bce6ea434335758683cb102f0e695757ce)
+++ src/config/database.js	(revision 2b4252c2c84fdd36dbdc75db1a4931f813b18a90)
@@ -71,4 +71,21 @@
     PRIMARY KEY (follow_id, guardian_uri)
   )`);
+  // Cross-instance follow-approval (modelled on the guardian offer): the
+  // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by
+  // the ward's server as an Offer(Follow). The decision is sent back to the
+  // ward's inbox. (Local wards use ap_pending_follows directly.)
+  db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews (
+    id TEXT NOT NULL,
+    guardian_slug TEXT NOT NULL,
+    ward_uri TEXT NOT NULL,
+    ward_inbox TEXT,
+    follower_uri TEXT NOT NULL,
+    follower_handle TEXT,
+    follower_icon TEXT,
+    follow_json TEXT,
+    status TEXT DEFAULT 'pending',
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (guardian_slug, id)
+  )`);
   ensureColumn('sites', 'profile_photo', 'TEXT');
   ensureColumn('audio_tracks', 'cover_url', 'TEXT');
Index: src/routes/guardian2.js
===================================================================
--- src/routes/guardian2.js	(revision af5b79bce6ea434335758683cb102f0e695757ce)
+++ src/routes/guardian2.js	(revision 2b4252c2c84fdd36dbdc75db1a4931f813b18a90)
@@ -148,8 +148,14 @@
   if (!site) return res.status(404).json({ error: 'no_site' });
   const items = [];
+  // Local wards (guardian co-located): read the pending follows directly.
   for (const wardSlug of wardSlugsOf(site)) {
     for (const f of Guardianship.follows.listForWard(wardSlug)) {
-      items.push({ id: f.id, ward: wardSlug, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, created: f.created_at });
+      items.push({ id: f.id, ward: wardSlug, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
     }
+  }
+  // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
+  for (const rev of Guardianship.follows.listReviews(site.slug)) {
+    const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
+    items.push({ id: rev.id, ward: wardName, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
   }
   res.json({ items });
@@ -162,7 +168,18 @@
   const me = AP.actorId(base, site.slug);
   const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
+
+  // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
+  // which tallies quorum and returns the Accept(Follow) to the follower.
+  const review = Guardianship.follows.getReview(site.slug, req.params.id);
+  if (review) {
+    try { await AP.sendFollowDecision(site, review, decision); }
+    catch { return res.status(502).json({ error: 'delivery' }); }
+    Guardianship.follows.removeReview(site.slug, req.params.id);
+    return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
+  }
+
+  // Local ward: decide directly (quorum on this instance).
   const pending = Guardianship.follows.getPending(req.params.id);
   if (!pending) return res.status(404).json({ error: 'gone' });
-  // I must actually be a guardian of this ward.
   const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
   if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision af5b79bce6ea434335758683cb102f0e695757ce)
+++ src/services/ActivityPubService.js	(revision 2b4252c2c84fdd36dbdc75db1a4931f813b18a90)
@@ -1323,4 +1323,10 @@
   }
 
+  // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
+  // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
+  if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
+    if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
+  }
+
   // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
   // Accept/Reject answers an offer a local guardian sent. Anything the
@@ -1393,13 +1399,25 @@
         name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
       });
-      // The ward and its guardians live together (the family Klonkt); a push
-      // tells each local guardian to decide in /guardian2. Over the wire the
-      // follow stays a normal pending Follow until they accept (Robins besluit:
-      // keep the flow simple, no cross-instance forwarding).
+      // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
+      // gated follow to its guardians for approval. A LOCAL guardian gets a
+      // push and reads /guardian2 directly; a REMOTE guardian gets an
+      // Offer(Follow) delivered so its instance stores a copy (same distributed
+      // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
+      const wardActor = actorId(base, slug);
+      const wardKeys = getOrCreateKeys(slug);
+      const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
       for (const g of wardGuardians) {
         const gslug = slugFromActorUrl(g);
-        if (!gslug) continue;
-        const L = pushLang(gslug);
-        pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
+        if (gslug) {
+          const L = pushLang(gslug);
+          pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
+        } else {
+          fetchActor(g).then((ga) => {
+            const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
+            if (!inbox) return;
+            const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true };
+            deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
+          }).catch(() => {});
+        }
       }
       console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
@@ -2954,4 +2972,68 @@
 }
 
+// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
+//    offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
+//    2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
+async function handleFollowApprovalInbox(act, slugParam) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const type = Array.isArray(act.type) ? act.type[0] : act.type;
+  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
+
+  // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
+  // signed by the ward, so act.actor is the ward.
+  if (type === 'Offer') {
+    const fo = (act.object && typeof act.object === 'object') ? act.object : null;
+    const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
+    if (!fo || foType !== 'Follow') return false;
+    const followId = fo.id;
+    const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
+    const wardUri = actorUri;
+    if (!followId || !follower || !wardUri) return false;
+    const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
+    if (slugParam) recips.push(actorId(base, slugParam));
+    let stored = false;
+    for (const r of new Set(recips)) {
+      const gslug = slugFromActorUrl(r);
+      if (!gslug) continue;
+      if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue;   // must actually guard this ward
+      const wardDoc = await fetchActor(wardUri).catch(() => null);
+      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
+      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
+      const L = pushLang(gslug);
+      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
+      stored = true;
+    }
+    return stored;
+  }
+
+  // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
+  const fo = act.object;
+  const followId = typeof fo === 'string' ? fo : (fo && fo.id);
+  if (!followId) return false;
+  const pending = Guardianship.follows.getPending(followId);
+  if (!pending) return false;
+  const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
+  if (!guardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
+  const decision = type === 'Reject' ? 'reject' : 'approve';
+  const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
+  try {
+    if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
+    else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
+  } catch { /* delivery is retried */ }
+  return true;
+}
+
+// Leg 3: a guardian in /guardian2 decides on a forwarded follow; send the
+// Accept/Reject back to the ward's inbox (signed by the guardian).
+export async function sendFollowDecision(guardianSite, review, decision) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const me = actorId(base, guardianSite.slug);
+  const keys = getOrCreateKeys(guardianSite.slug);
+  const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
+  const activity = { '@context': AP_CONTEXT, id: `${me}#followdec-${Date.now()}-${rid()}`, type: decision === 'reject' ? 'Reject' : 'Accept', actor: me, to: [review.ward_uri], object: fo, 'shaer:followApproval': true };
+  if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
+  return { ok: true };
+}
+
 // Send a Like or Announce (boost) on a remote note FROM this site.
 export async function sendInteraction(site, kind, targetNoteId, authorUri) {
@@ -3268,5 +3350,5 @@
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll,
-  acceptGatedFollow, rejectGatedFollow, isWardGuardian,
+  acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
Index: src/services/guardianship/follows.js
===================================================================
--- src/services/guardianship/follows.js	(revision af5b79bce6ea434335758683cb102f0e695757ce)
+++ src/services/guardianship/follows.js	(revision 2b4252c2c84fdd36dbdc75db1a4931f813b18a90)
@@ -73,3 +73,32 @@
 export function remove(id) { stmts().del.run(id); }
 
-export default { recordPending, getPending, listForWard, decide, remove };
+// ── Guardian-side copy (cross-instance, modelled on the guardian offer): a
+//    gated follow on a REMOTE ward this account guards, forwarded here as an
+//    Offer(Follow). The decision is Accept/Reject sent back to ward_inbox. ──
+let _r = null;
+function rstmts() {
+  if (!_r) {
+    _r = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
+        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
+        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
+      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
+      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
+      del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
+    };
+  }
+  return _r;
+}
+
+export function recordReview(guardianSlug, r) {
+  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
+  return rstmts().get.get(guardianSlug, r.id);
+}
+export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
+export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
+export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
+
+export default {
+  recordPending, getPending, listForWard, decide, remove,
+  recordReview, getReview, listReviews, removeReview,
+};
