Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 3d882bdbd7d45a727ffdcb64eacb870baecdb05d)
+++ src/services/ActivityPubService.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
@@ -2487,4 +2487,15 @@
         const actorUri = c2sIdOf(object);
         if (!actorUri) return { status: 400, error: 'missing_object' };
+        // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
+        // A held request is a THIRD outcome — not sent, not failed — and it
+        // travels to the app as one, so Shaer can show "waiting" instead of a
+        // tile that already looks followed.
+        const held = await gateOutgoingFollow(site, actorUri);
+        if (held) {
+          return {
+            status: 202, url: actorUri, id: held.id,
+            state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
+          };
+        }
         // The error REACHES the app (Robins melding, 31-7): swallowing it
         // made a failed follow look exactly like a successful one.
@@ -4024,4 +4035,89 @@
 // Accept to the follower and record them, so delivery (incl. followers-only)
 // begins. `pending` is a row from ap_pending_follows.
+/**
+ * FEP-633c §5.3, the direction that was never gated (bead shaer-p729).
+ *
+ * A ward's OWN follow waited for nobody: it went straight out and the guardians
+ * got a note afterwards (1a2f206). That is informing, not gating — the door is
+ * already open when the message lands. Now it waits, with two exceptions that
+ * are not favours but the same decision already taken:
+ *
+ *   - the target is one of the ward's own guardians. Following the adult who
+ *     watches over you is not a question anyone needs to answer.
+ *   - the target already follows the ward THROUGH THE GATE. A guardian
+ *     approved that person by name; asking again about the same person only
+ *     teaches everyone to stop reading the question.
+ *
+ * Returns the held request, or null when the follow may go out now.
+ * Deliberately not a boolean: a held follow must be distinguishable from a sent
+ * one all the way up to the app, which is the lesson the error path already
+ * learned (Robins melding, 31-7).
+ */
+export async function gateOutgoingFollow(site, targetUri) {
+  const slug = site && site.slug;
+  if (!slug || !targetUri) return null;
+  const guardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
+  if (!guardians.length) return null;                                   // not a ward: nothing to gate
+  if (guardians.includes(targetUri)) return null;                       // your own guardian
+  if (Guardianship.outgoing.isMutual(slug, targetUri)) return null;     // already vetted by name
+
+  const seen = Guardianship.outgoing.findFor(slug, targetUri);
+  if (seen && seen.status === 'approved') return null;                  // the guardians said yes already
+  if (seen && (seen.status === 'pending' || seen.status === 'denied')) return seen;
+
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const wardActor = actorId(base, slug);
+  const target = await fetchActor(targetUri).catch(() => null);
+  const ti = actorInfo(target, targetUri);
+  const id = `${wardActor}#outfollow-${Date.now()}-${rid()}`;
+  const held = Guardianship.outgoing.recordPending(slug, {
+    id, target: targetUri,
+    inbox: target && ((target.endpoints && target.endpoints.sharedInbox) || target.inbox),
+    name: ti.name, handle: ti.handle, icon: ti.icon,
+  });
+
+  // Same routing as the inbound gate: a guardian on this instance gets a push
+  // and reads /guardian; one elsewhere gets an Offer delivered so its own
+  // server holds a copy to answer from.
+  const wardKeys = getOrCreateKeys(slug);
+  const followObj = { id, type: 'Follow', actor: wardActor, object: targetUri };
+  for (const g of guardians) {
+    try { Guardianship.availability.recordRequest(slug, g, id, Date.now()); } catch { /* never load-bearing */ }
+  }
+  for (const g of guardians) {
+    const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
+    const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
+    if (isLocal) {
+      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: ti.name || ti.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
+    } 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}#outfollowoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:direction': 'outgoing' };
+        deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
+      }).catch(() => {});
+    }
+  }
+  console.log('[AP] outgoing Follow', slug, '→', targetUri, '(gated, awaiting guardians)');
+  return held || { id, ward_slug: slug, target_uri: targetUri, status: 'pending' };
+}
+
+/**
+ * The guardians said yes: send the ward's Follow for real (§5.3, shaer-p729).
+ *
+ * The row stays behind as `approved` rather than being deleted. It is the
+ * record that these guardians vetted this target, so an unfollow-and-refollow
+ * later does not put the same question in front of them again.
+ */
+export async function performApprovedFollow(pending) {
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(pending.ward_slug);
+  if (!site) return { error: 'no_such_ward' };
+  const r = await followActor(site, pending.target_uri);
+  if (r && r.error) return { error: r.error };
+  console.log('[AP] outgoing Follow approved', pending.ward_slug, '→', pending.target_uri);
+  return { ok: true };
+}
+
 export async function acceptGatedFollow(pending) {
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
@@ -4030,4 +4126,8 @@
   const keys = getOrCreateKeys(slug);
   fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
+  // This follower came through the §5.3 gate: a guardian said yes to this
+  // person by name. That is precisely what lets the ward follow them back later
+  // without asking the same guardians the same question twice (shaer-p729).
+  db.prepare('UPDATE ap_followers SET gate_approved = 1 WHERE slug = ? AND actor_uri = ?').run(slug, pending.follower_uri);
   const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
   const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
@@ -4512,4 +4612,5 @@
   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
+  gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision 3d882bdbd7d45a727ffdcb64eacb870baecdb05d)
+++ src/services/guardianship/index.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
@@ -19,8 +19,9 @@
 export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship } from './handshake.js';
-export { offersCollection, followsCollection, wardsCollection, guardiansCollection } from './queues.js';
+export { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection } from './queues.js';
 export * as availability from './availability.js';
 export { wireAvailability } from './availability.js';
 export * as follows from './follows.js';
+export * as outgoing from './outgoing.js';
 export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
 export {
Index: src/services/guardianship/outgoing.js
===================================================================
--- src/services/guardianship/outgoing.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
+++ src/services/guardianship/outgoing.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
@@ -0,0 +1,117 @@
+/**
+ * Guardianship (FEP-633c §5.3, the other direction) — gating a ward's OWN
+ * follows. Bead shaer-p729; the design is in docs/ward-outbound-follows-design.md,
+ * and the spec question it answers is shaer-yeo5.
+ *
+ * The inbound gate in `follows.js` decides who may follow a ward. This one
+ * decides who a ward may follow. Until now that went out unchecked: the
+ * guardians got a note afterwards (1a2f206), which is informing, not gating —
+ * the door is already open by the time the message arrives.
+ *
+ * The rule (Barts besluit): every outgoing follow waits for a guardian, EXCEPT
+ * where the target already follows the ward through the gate. A guardian
+ * already said yes to that person; asking the same question twice only teaches
+ * people to stop reading the question.
+ */
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_outgoing_follows
+        (id, ward_slug, target_uri, target_inbox, target_name, target_handle, target_icon, quorum, created_at)
+        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
+      get: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE id = ?'),
+      byTarget: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
+      byWard: db.prepare("SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
+      approve: db.prepare('INSERT OR IGNORE INTO ap_outgoing_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
+      answers: db.prepare('SELECT guardian_uri, decision FROM ap_outgoing_follow_approvals WHERE follow_id = ?'),
+      setStatus: db.prepare('UPDATE ap_pending_outgoing_follows SET status = ? WHERE id = ?'),
+      del: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE id = ?'),
+      delByTarget: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
+      gateApproved: db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? AND gate_approved = 1'),
+    };
+  }
+  return _s;
+}
+
+/**
+ * Does this target already follow the ward, with a guardian's blessing?
+ *
+ * Only a gate-approved follower counts. A follower a free actor picked up
+ * before it was ever a ward was never seen by a guardian, so following them
+ * back is a new question, not a settled one. (Rows that predate the marker are
+ * grandfathered at migration; see config/database.js.)
+ */
+export function isMutual(wardSlug, targetUri) {
+  return !!stmts().gateApproved.get(wardSlug, targetUri);
+}
+
+/** Record an outgoing follow awaiting guardian approval. */
+export function recordPending(wardSlug, f) {
+  stmts().ins.run(
+    f.id, wardSlug, f.target, f.inbox || null,
+    f.name || null, f.handle || null, f.icon || null, f.quorum || 'any',
+  );
+  return stmts().byTarget.get(wardSlug, f.target);
+}
+
+export function getPending(id) { return stmts().get.get(id); }
+export function findFor(wardSlug, targetUri) { return stmts().byTarget.get(wardSlug, targetUri); }
+
+/** Outgoing follows this ward is waiting on — the guardian's queue. */
+export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
+
+/**
+ * A guardian's answer. Same shape and the same quorum arithmetic as the
+ * inbound gate, so the two directions cannot drift apart in how they count:
+ * a single reject denies outright, approvals accumulate toward the quorum.
+ */
+export function decide(id, guardianUri, decision, guardiansOfWard) {
+  const follow = stmts().get.get(id);
+  if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
+  stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
+  const rows = stmts().answers.all(id);
+  if (rows.some((r) => r.decision === 'reject')) {
+    stmts().setStatus.run('denied', id);
+    return { outcome: 'rejected', follow };
+  }
+  const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
+  const guardians = (guardiansOfWard || []).filter(Boolean);
+  const enough = follow.quorum === 'all'
+    ? guardians.length > 0 && guardians.every((g) => approvers.has(g))
+    : approvers.size >= 1;                                   // 'any' (default)
+  if (enough) {
+    stmts().setStatus.run('approved', id);
+    return { outcome: 'approved', follow };
+  }
+  return { outcome: 'waiting', follow };
+}
+
+/** The ward changed its mind, or blocked the target: the request is gone. */
+export function withdraw(wardSlug, targetUri) { stmts().delByTarget.run(wardSlug, targetUri); }
+export function remove(id) { stmts().del.run(id); }
+
+/** One request as the queue item the Shaer clients parse, mirroring the
+ *  inbound gated-follow item so a dashboard can render both side by side. */
+export function queueItem(o, me) {
+  const rows = stmts().answers.all(o.id);
+  return {
+    id: o.id,
+    type: 'Follow',
+    actor: o.ward_slug,
+    object: o.target_uri,
+    'shaer:direction': 'outgoing',
+    'shaer:target': o.target_uri,
+    'shaer:targetHandle': o.target_handle || undefined,
+    'shaer:quorum': o.quorum || 'any',
+    'shaer:approvals': rows.filter((r) => r.decision === 'approve').length,
+    'shaer:myVote': rows.some((r) => r.guardian_uri === me),
+    published: o.created_at,
+  };
+}
+
+export default {
+  isMutual, recordPending, getPending, findFor, listForWard, decide, withdraw, remove, queueItem,
+};
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision 3d882bdbd7d45a727ffdcb64eacb870baecdb05d)
+++ src/services/guardianship/queues.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
@@ -12,4 +12,5 @@
 import * as relations from './relations.js';
 import * as availability from './availability.js';
+import * as outgoing from './outgoing.js';
 import * as handshake from './handshake.js';
 
@@ -39,4 +40,9 @@
 }
 
+/** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
+export function outgoingFollowsCollection(id, slug, me) {
+  return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
+}
+
 /** The guardian's committed wards, with cached handle for display. */
 export function wardsCollection(id, slug) {
@@ -53,3 +59,3 @@
 }
 
-export default { offersCollection, followsCollection, wardsCollection, guardiansCollection };
+export default { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection };
Index: src/services/guardianship/relations.js
===================================================================
--- src/services/guardianship/relations.js	(revision 3d882bdbd7d45a727ffdcb64eacb870baecdb05d)
+++ src/services/guardianship/relations.js	(revision fa33214eb34752fafee25fc95942f470701006f8)
@@ -73,4 +73,9 @@
       offers: `${id}/queues/offers`,
       follows: `${id}/queues/follows`,
+      // Both directions of §5.3, kept apart on purpose: a guardian must be able
+      // to tell "someone wants to follow your ward" from "your ward wants to
+      // follow someone". Same mechanism, opposite question, different words in
+      // the interface (shaer-p729).
+      outgoingFollows: `${id}/queues/outgoing-follows`,
       wards: `${id}/queues/wards`,
       guardians: `${id}/queues/guardians`,
