Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 9a00f283dea4b5647c585540cdef7d52ced93298)
+++ src/config/database.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -405,4 +405,16 @@
     );
     CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
+    CREATE TABLE IF NOT EXISTS ap_guardianships (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      slug TEXT NOT NULL,          -- our local site in this relation (guardianship module)
+      role TEXT NOT NULL,          -- 'guardian' (slug guards other) | 'ward' (other guards slug)
+      other_uri TEXT NOT NULL,     -- the counterpart actor URI (local or remote)
+      other_handle TEXT,           -- cached @user@host for display
+      status TEXT NOT NULL,        -- 'offered' (handshake pending) | 'accepted'
+      offer_id TEXT,               -- the Offer activity id (FEP-633c section 3)
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      UNIQUE(slug, role, other_uri)
+    );
+    CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
     CREATE TABLE IF NOT EXISTS ap_delivery (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -485,4 +497,5 @@
   ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
   ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
+  ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
   ensureColumn('ap_followers', 'name', 'TEXT');    // cached display name (shaer-aa3)
   ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 9a00f283dea4b5647c585540cdef7d52ced93298)
+++ src/services/ActivityPubService.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -25,4 +25,6 @@
 import { getTenancy } from './SettingsService.js';
 import { t as i18nT } from './i18n.js';
+import Blocklist from './BlocklistService.js';
+import * as Guardianship from './guardianship/index.js';
 
 const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
@@ -49,7 +51,7 @@
     // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
     votersCount: 'toot:votersCount',
-    // FEP-633c (Guardians): the shaer namespace. helpRequest marks a direct
-    // note as a ward's call for help (spec 5.2.1); ignorable by everyone else.
-    shaer: 'https://ns.klonkt.com/shaer#',
+    // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
+    // module (src/services/guardianship/).
+    ...Guardianship.SHAER_CONTEXT,
   },
 ];
@@ -174,4 +176,7 @@
     // separate state.
     blocked: `${id}/blocked`,
+    // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
+    // (guardianship module owns these).
+    ...Guardianship.guardianshipActorProps(id, site.slug),
     // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
     // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
@@ -270,5 +275,5 @@
       cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`],
       // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
-      'shaer:helpRequest': (post.visibility === 'direct' && post.help_request) ? true : undefined,
+      ...Guardianship.helpRequestProps(post),
       tag: [
         ...mentionTags(post.content),
@@ -1303,9 +1308,35 @@
   // Blocked actor/domain → silently drop (202, don't reveal the block).
   if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
-  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag'];
+  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
   if (GATED.includes(type)) {
     if (!verified || !claimedActor || verified.id !== claimedActor) {
       console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
       return 401;
+    }
+  }
+
+  // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
+  // Accept/Reject answers an offer a local guardian sent. Anything the
+  // guardianship module does not recognize falls through to the old paths.
+  if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
+    let gslug = slugParam || null;
+    if (!gslug && type === 'Offer') {
+      const rel = Guardianship.parseRelationship(act.object);
+      if (rel) gslug = slugFromActorUrl(rel.ward);
+    }
+    if (!gslug) {
+      const offerId = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
+      const rows = offerId ? Guardianship.findByOfferId?.(offerId) || [] : [];
+      if (rows.length) gslug = rows[0].slug;
+      if (!gslug) for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
+        const s = slugFromActorUrl(t); if (s) { gslug = s; break; }
+      }
+    }
+    if (gslug) {
+      const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(gslug);
+      if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) {
+        console.log('[AP] guardianship', type, 'for', gslug, 'from', claimedActor);
+        return 202;
+      }
     }
   }
@@ -1463,10 +1494,13 @@
         const ai = actorInfo(await resolveActor(actorUri), actorUri);
         const html = HtmlSanitizerService.sanitize(o.content || '');
+        // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
+        // flag is stored so the Guardian PWA's message centre can list it.
+        const help = Guardianship.isHelpRequest(o);
         for (const slug of slugs) {
           try {
-            const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
-              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null);
+            const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
+              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0);
             if (r.changes) {
-              console.log('[AP] mention', actorUri, '→', slug);
+              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
               const vis = noteVisibility(o);
               const priv = vis === 'direct' || vis === 'followers';
@@ -1474,5 +1508,8 @@
               const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
               // Same privacy rule as replies: private mentions push without content.
-              if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
+              // A help request pushes as its own alert type, aimed at the
+              // Guardian PWA's message centre.
+              if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
+              else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
               else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
             }
@@ -1959,4 +1996,12 @@
   if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
   if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
+
+  // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
+  // Relationship) belongs to the guardianship module; anything else falls
+  // through to the switch below.
+  if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
+    const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
+    if (g) return g;
+  }
 
   try {
@@ -2097,74 +2142,9 @@
 }
 
-// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
-// safest bucket they match.
-export function c2sVisibility(object) {
-  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
-  const to = arr(object.to), cc = arr(object.cc);
-  const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
-  const isFollowers = (x) => /\/followers\/?$/.test(x);
-  if (to.some(isPublic)) return 'public';
-  if (cc.some(isPublic)) return 'quiet';
-  if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
-  if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
-  return 'direct';
-}
-
-// A direct note (private mention, shaer-tqc): a NEW conversation (or a direct
-// reply) addressed to specific actors only. Stored in ap_outbox with
-// visibility 'direct' + the recipient list, delivered to exactly those
-// inboxes: no followers fan-out, no Public, so no boosts and no timelines.
-// The same S2S leg a Mastodon DM takes, so a guardian on any instance
-// receives it as a private mention (the ward call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest }) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
-  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
-  const me = actorId(base, site.slug);
-  // Resolve every recipient for a mention anchor + a delivery inbox.
-  const resolved = [];
-  for (const uri of list) {
-    const a = await fetchActor(uri).catch(() => null);
-    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
-    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });
-  }
-  if (!resolved.length) return null;
-  const mention = resolved.map((r) => {
-    const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
-    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
-  }).join('');
-  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
-  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
-  // Attachments: same rules as deliverReply (own /media/ uploads only,
-  // image/audio/video, max 4) — the help-buoy capture rides this.
-  const media = (Array.isArray(attachments) ? attachments : [])
-    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
-      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
-    .slice(0, 4)
-    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
-  const id = crypto.randomUUID();
-  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, created_at)
-              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
-    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0);
-  const row = iStmts().getO.get(id);
-  const note = buildReplyNote(base, site, row);
-  const create = {
-    '@context': AP_CONTEXT,
-    id: note.id + '#create', type: 'Create', actor: me,
-    published: note.published, to: note.to, cc: note.cc, object: note,
-  };
-  const keys = getOrCreateKeys(site.slug);
-  const keyId = `${me}#main-key`;
-  let delivered = 0;
-  for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) {
-    let ok = false;
-    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
-    if (ok) delivered++;
-    else enqueueDelivery(site.slug, inbox, create);
-  }
-  console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
-  return { id, content, delivered };
-}
+// The direct-note leg (ward call-for-help) lives in the guardianship module
+// (src/services/guardianship/delivery.js); wired with our AP helpers at the
+// bottom of this file. Re-exported so every existing caller keeps working.
+export const c2sVisibility = Guardianship.c2sVisibility;
+export const deliverDirectNote = Guardianship.deliverDirectNote;
 
 // Send a reply FROM this site to a remote actor (in reply to their inbound reply).
@@ -3026,14 +3006,7 @@
 
 // ── Blocking / defederation ───────────────────────────────────────
-let _insBl, _delBl, _listBl;
-function blStmts() {
-  if (!_insBl) {
-    _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
-    _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
-    _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
-  }
-  return { ins: _insBl, del: _delBl, list: _listBl };
-}
-export function listBlocks(slug) { return blStmts().list.all(slug); }
+// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
+// Orbit"). Thin delegations keep every existing caller working.
+export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
 
 // True if an actor (or its whole domain) is blocked anywhere on this instance.
@@ -3140,51 +3113,56 @@
 }
 
-export function isBlockedAny(actorUri) {
-  if (!actorUri) return false;
-  let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
-  try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
-  catch { return false; }
-}
-
-function purgeBlocked(kind, target) {
+export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
+
+// Block an actor (@handle or actor URL) or a whole domain; purges their content.
+// The handle resolver is ours; the storage/purge lives in BlocklistService.
+export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); }
+
+export function unblock(site, target) { return Blocklist.unblock(site, target); }
+
+// ── Guardianship module wiring (src/services/guardianship/) ────────
+// The module owns FEP-633c (context, relations, handshake, queues, the
+// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
+// imports us back.
+function selfActorId(slug) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  return actorId(base, slug);
+}
+// Deliver one activity to one actor's inbox, signed; queued on failure.
+async function deliverToActor(site, actorUri, activity) {
+  const a = await fetchActor(actorUri).catch(() => null);
+  const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
+  if (!inbox) return false;
+  const me = selfActorId(site.slug);
+  const keys = getOrCreateKeys(site.slug);
+  const payload = { '@context': AP_CONTEXT, ...activity };
   try {
-    if (kind === 'domain') {
-      // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
-      // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
-      const purge = (table, col) => {
-        let rows = [];
-        try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
-        const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
-        for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } }
-      };
-      purge('ap_interactions', 'actor_uri');
-      purge('ap_timeline', 'author_uri');
-      purge('ap_followers', 'actor_uri');
-    } else {
-      db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
-      db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
-      db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
-    }
-  } catch { /* best-effort */ }
-}
-
-// Block an actor (@handle or actor URL) or a whole domain; purges their content.
-export async function blockTarget(site, input) {
-  const raw = String(input || '').trim();
-  if (!site || !site.slug || !raw) return { error: 'empty' };
-  let kind, target, label;
-  if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
-  else if (raw.includes('@')) {
-    const actorUrl = await webfingerResolve(raw);
-    if (!actorUrl) return { error: 'not_found' };
-    kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
-  } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
-  blStmts().ins.run(site.slug, target, kind, label);
-  purgeBlocked(kind, target);
-  console.log('[AP] block', site.slug, kind, target);
-  return { ok: true, label };
-}
-
-export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
+    const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
+    if (st >= 200 && st < 300) return true;
+  } catch { /* fall through to the queue */ }
+  enqueueDelivery(site.slug, inbox, payload);
+  return true;   // queued: it will arrive
+}
+Guardianship.wireDelivery({
+  actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
+  getOutboxRow: (id) => iStmts().getO.get(id),
+  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
+});
+Guardianship.wireHandshake({
+  selfId: selfActorId,
+  deliverTo: deliverToActor,
+  deriveHandle,
+  // Guardian PWA push: an offer or an answer lands as a notification.
+  onEvent: (slug, ev) => {
+    const L = pushLang(slug);
+    const texts = {
+      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],
+      ward_accepted: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
+    }[ev.kind];
+    if (!texts) return;
+    const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?';
+    pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url: '/guardian' });
+  },
+});
 
 export default {
Index: src/services/BlocklistService.js
===================================================================
--- src/services/BlocklistService.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/BlocklistService.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,76 @@
+/**
+ * The instance blocklist (ap_blocks): actors and whole domains a site has
+ * blocked. Lives NEXT TO the guardianship module, not inside it, because it
+ * is shared: Klonkt's own Block tab uses it, and Shaer's "in Orbit" reads it
+ * as the source of truth (AP §5.6 blocked collection, owner-only).
+ *
+ * Extracted from ActivityPubService (guardianship refactor); behavior is
+ * unchanged. ActivityPubService re-exports these under the old names so
+ * existing callers keep working.
+ */
+import db from '../config/database.js';
+
+let _insBl, _delBl, _listBl;
+function blStmts() {
+  if (!_insBl) {
+    _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
+    _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
+    _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
+  }
+  return { ins: _insBl, del: _delBl, list: _listBl };
+}
+
+export function listBlocks(slug) { return blStmts().list.all(slug); }
+
+// True if an actor (or its whole domain) is blocked anywhere on this instance.
+export function isBlockedAny(actorUri) {
+  if (!actorUri) return false;
+  let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
+  try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
+  catch { return false; }
+}
+
+function purgeBlocked(kind, target) {
+  try {
+    if (kind === 'domain') {
+      // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
+      // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
+      const purge = (table, col) => {
+        let rows = [];
+        try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
+        const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
+        for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } }
+      };
+      purge('ap_interactions', 'actor_uri');
+      purge('ap_timeline', 'author_uri');
+      purge('ap_followers', 'actor_uri');
+    } else {
+      db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
+      db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
+      db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
+    }
+  } catch { /* best-effort */ }
+}
+
+// Block an actor (@handle or actor URL) or a whole domain; purges their content.
+// `resolveHandle` (async handle → actor URL) is injected by the caller so this
+// service needs nothing from ActivityPubService (no circular import).
+export async function blockTarget(site, input, resolveHandle) {
+  const raw = String(input || '').trim();
+  if (!site || !site.slug || !raw) return { error: 'empty' };
+  let kind, target, label;
+  if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
+  else if (raw.includes('@')) {
+    const actorUrl = resolveHandle ? await resolveHandle(raw) : null;
+    if (!actorUrl) return { error: 'not_found' };
+    kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
+  } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
+  blStmts().ins.run(site.slug, target, kind, label);
+  purgeBlocked(kind, target);
+  console.log('[AP] block', site.slug, kind, target);
+  return { ok: true, label };
+}
+
+export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
+
+export default { listBlocks, isBlockedAny, blockTarget, unblock };
Index: src/services/guardianship/context.js
===================================================================
--- src/services/guardianship/context.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/context.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,26 @@
+/**
+ * Guardianship (FEP-633c "Guardians") — JSON-LD vocabulary.
+ *
+ * One source of truth for the shaer namespace and the terms Klonkt emits.
+ * ActivityPubService spreads SHAER_CONTEXT into its AP_CONTEXT term block, so
+ * every outgoing document declares the namespace and strict JSON-LD
+ * processors resolve the terms instead of dropping them.
+ */
+
+/** The term block merged into AP_CONTEXT. */
+export const SHAER_CONTEXT = {
+  // FEP-633c (Guardians): the shaer namespace. helpRequest marks a direct
+  // note as a ward's call for help (spec 5.2.1); ignorable by everyone else.
+  shaer: 'https://ns.klonkt.com/shaer#',
+};
+
+/** The Relationship value in the adoption Offer (FEP-633c §3), both forms. */
+export const GUARDIAN_RELATIONSHIP = 'https://ns.klonkt.com/shaer#Guardian';
+export const GUARDIAN_RELATIONSHIP_COMPACT = 'shaer:Guardian';
+
+/** True when an Offer's relationship names the guardian relation. */
+export function isGuardianRelationship(value) {
+  return value === GUARDIAN_RELATIONSHIP || value === GUARDIAN_RELATIONSHIP_COMPACT;
+}
+
+export default { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship };
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/delivery.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,95 @@
+/**
+ * Guardianship (FEP-633c) — the direct-note delivery leg.
+ *
+ * A direct note (private mention, shaer-tqc) is the ward's call-for-help
+ * carrier: addressed to specific actors only, no Public, no followers
+ * fan-out. Moved here from ActivityPubService (guardianship refactor);
+ * behavior is unchanged.
+ *
+ * This module has NO import back into ActivityPubService: the AP helpers it
+ * needs (actor fetch, key material, delivery, note building) are provided
+ * once via wireDelivery(deps) at ActivityPubService load time.
+ */
+import crypto from 'crypto';
+import db from '../../config/database.js';
+
+const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
+
+let deps = null;
+/** Called once by ActivityPubService with the shared AP helpers. */
+export function wireDelivery(d) { deps = d; }
+
+// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
+// safest bucket they match.
+export function c2sVisibility(object) {
+  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
+  const to = arr(object.to), cc = arr(object.cc);
+  const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
+  const isFollowers = (x) => /\/followers\/?$/.test(x);
+  if (to.some(isPublic)) return 'public';
+  if (cc.some(isPublic)) return 'quiet';
+  if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
+  if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
+  return 'direct';
+}
+
+// A direct note: a NEW conversation (or a direct reply) addressed to specific
+// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
+// list, delivered to exactly those inboxes: no followers fan-out, no Public,
+// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
+// guardian on any instance receives it as a private mention (the ward
+// call-for-help path).
+export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest }) {
+  const { actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
+          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
+  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
+  const me = actorId(base, site.slug);
+  // Resolve every recipient for a mention anchor + a delivery inbox.
+  const resolved = [];
+  for (const uri of list) {
+    const a = await fetchActor(uri).catch(() => null);
+    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
+    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });
+  }
+  if (!resolved.length) return null;
+  const mention = resolved.map((r) => {
+    const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
+    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
+  }).join('');
+  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
+  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
+  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
+  // Attachments: same rules as deliverReply (own /media/ uploads only,
+  // image/audio/video, max 4) — the help-buoy capture rides this.
+  const media = (Array.isArray(attachments) ? attachments : [])
+    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
+      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
+    .slice(0, 4)
+    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
+  const id = crypto.randomUUID();
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, created_at)
+              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
+    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0);
+  const row = getOutboxRow(id);
+  const note = buildReplyNote(base, site, row);
+  const create = {
+    '@context': AP_CONTEXT,
+    id: note.id + '#create', type: 'Create', actor: me,
+    published: note.published, to: note.to, cc: note.cc, object: note,
+  };
+  const keys = getOrCreateKeys(site.slug);
+  const keyId = `${me}#main-key`;
+  let delivered = 0;
+  for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) {
+    let ok = false;
+    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
+    if (ok) delivered++;
+    else enqueueDelivery(site.slug, inbox, create);
+  }
+  console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
+  return { id, content, delivered };
+}
+
+export default { wireDelivery, c2sVisibility, deliverDirectNote };
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/handshake.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,135 @@
+/**
+ * Guardianship (FEP-633c §3) — the adoption handshake.
+ *
+ * Offer(Relationship{subject: ward, relationship: shaer:Guardian, object:
+ * candidate}) travels from the guardian-candidate to the ward; the ward
+ * answers Accept (relation becomes real) or Reject (row disappears). The
+ * shape mirrors the Shaer test daemon, so the iOS/Android clients speak it
+ * unchanged.
+ *
+ * Wired like delivery.js: no import back into ActivityPubService; the AP
+ * helpers arrive once via wireHandshake(deps). `deps.onEvent(slug, ev)` is an
+ * optional hook the Guardian PWA uses for push notifications.
+ */
+import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
+import * as relations from './relations.js';
+
+let deps = null;
+export function wireHandshake(d) { deps = d; }
+
+const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
+
+/** Parse a Relationship object into {ward, candidate} or null. */
+export function parseRelationship(rel) {
+  if (!rel || typeof rel !== 'object') return null;
+  const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
+  if (type !== 'Relationship') return null;
+  if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
+  const ward = idOf(rel.subject);
+  const candidate = idOf(rel.object);
+  return ward && candidate ? { ward, candidate } : null;
+}
+
+// ── C2S: the local account acts (PWA or Shaer app, via the outbox) ────────
+
+/**
+ * Handle a guardianship activity POSTed to the local outbox. Returns null
+ * when the activity is not ours to handle, else {status, ...} for the route.
+ */
+export async function handleOutbox(site, activity) {
+  const { selfId, deliverTo, deriveHandle } = deps;
+  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
+  if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
+  const me = selfId(site.slug);
+
+  if (type === 'Offer') {
+    const rel = parseRelationship(activity.object);
+    if (!rel) return null;                                   // not a guardianship offer
+    // Fixed initiator (FEP resolved B): only the aspirant guardian offers.
+    if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' };
+    // A ward can never become a guardian (FEP §1).
+    if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' };
+    const offerId = `${me}/offers/${Date.now().toString(36)}`;
+    const offer = {
+      id: offerId, type: 'Offer', actor: me, to: [rel.ward],
+      object: { type: 'Relationship', subject: rel.ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
+    };
+    relations.recordOffer(site.slug, 'guardian', rel.ward, { handle: deriveHandle(rel.ward), offerId });
+    const delivered = await deliverTo(site, rel.ward, offer).catch(() => false);
+    notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
+    return { status: delivered ? 202 : 502, id: offerId, url: offerId };
+  }
+
+  // Accept / Reject: the local ward answers a pending offer.
+  const obj = activity.object;
+  const offerId = idOf(obj);
+  const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
+  let row = null;
+  if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'ward') || null;
+  if (!row && rel) row = relations.getRelation(site.slug, 'ward', rel.candidate) || null;
+  if (!row) return { status: 404, error: 'no_such_offer' };
+
+  const answer = {
+    id: `${me}/answers/${Date.now().toString(36)}`, type, actor: me, to: [row.other_uri],
+    object: row.offer_id || { type: 'Relationship', subject: me, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: row.other_uri },
+  };
+  if (type === 'Accept') {
+    // The committed handle rides in `result` (daemon contract): the guardian
+    // learns where the ward lives.
+    answer.result = `${me}/inbox`;
+    relations.acceptRelation(site.slug, 'ward', row.other_uri);
+  } else {
+    relations.removeRelation(site.slug, 'ward', row.other_uri);
+  }
+  const delivered = await deliverTo(site, row.other_uri, answer).catch(() => false);
+  notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri });
+  return { status: delivered ? 202 : 502, id: answer.id, url: answer.id };
+}
+
+// ── S2S: a remote party acts (arrives in the local inbox) ────────────────
+
+/**
+ * Handle an inbound guardianship activity for local site `site`. Returns
+ * true when consumed (the generic inbox skips it), false otherwise.
+ */
+export async function handleInbox(site, activity) {
+  const { selfId } = deps;
+  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
+  if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
+  const me = selfId(site.slug);
+  const actor = idOf(activity.actor);
+
+  if (type === 'Offer') {
+    const rel = parseRelationship(activity.object);
+    if (!rel || rel.ward !== me) return false;
+    // A remote candidate offers to guard the local ward: park it in the queue.
+    relations.recordOffer(site.slug, 'ward', rel.candidate, { handle: deps.deriveHandle(rel.candidate), offerId: idOf(activity) });
+    notify(site.slug, { kind: 'offer_received', candidate: rel.candidate });
+    return true;
+  }
+
+  // Accept / Reject of an offer WE (local guardian) sent.
+  const obj = activity.object;
+  const offerId = idOf(obj);
+  const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
+  let row = null;
+  if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'guardian') || null;
+  if (!row && actor) row = relations.getRelation(site.slug, 'guardian', actor) || null;
+  if (!row && rel) row = relations.getRelation(site.slug, 'guardian', rel.ward) || null;
+  if (!row) return false;
+
+  if (type === 'Accept') {
+    relations.acceptRelation(site.slug, 'guardian', row.other_uri);
+    notify(site.slug, { kind: 'ward_accepted', ward: row.other_uri });
+  } else {
+    relations.removeRelation(site.slug, 'guardian', row.other_uri);
+    notify(site.slug, { kind: 'ward_rejected', ward: row.other_uri });
+  }
+  return true;
+}
+
+function notify(slug, ev) {
+  try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
+}
+
+export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/index.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,26 @@
+/**
+ * Guardianship (FEP-633c "Guardians") — the module.
+ *
+ * Klonkt's kid-safety feature as one cohesive unit:
+ *  - context.js:   the shaer JSON-LD namespace + Relationship vocabulary
+ *  - relations.js: ward ↔ guardian relations (ap_guardianships) + actor props
+ *  - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S
+ *  - queues.js:    the owner-only dashboard collections (offers/follows/wards)
+ *  - notes.js:     the shaer:helpRequest flag on direct notes
+ *  - delivery.js:  the direct-note leg a ward's call-for-help rides
+ *
+ * The shared blocklist (Shaer's "in Orbit") intentionally lives NEXT TO this
+ * module in BlocklistService: Klonkt's own Block tab uses it too.
+ *
+ * ActivityPubService wires the AP helpers in once (wireDelivery/wireHandshake)
+ * and delegates; nothing here imports ActivityPubService back.
+ */
+export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
+export { helpRequestProps, isHelpRequest } from './notes.js';
+export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
+export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js';
+export { offersCollection, followsCollection, wardsCollection } from './queues.js';
+export {
+  listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
+  recordOffer, acceptRelation, removeRelation, actorProps as guardianshipActorProps,
+} from './relations.js';
Index: src/services/guardianship/notes.js
===================================================================
--- src/services/guardianship/notes.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/notes.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,20 @@
+/**
+ * Guardianship (FEP-633c) — note properties.
+ *
+ * The shaer:helpRequest flag (spec 5.2.1): a ward's call for help, only ever
+ * on direct notes. Everyone who does not speak shaer can ignore it.
+ */
+
+/** Extra JSON-LD properties for an outgoing note built from an ap_outbox row. */
+export function helpRequestProps(post) {
+  return (post && post.visibility === 'direct' && post.help_request)
+    ? { 'shaer:helpRequest': true }
+    : {};
+}
+
+/** True when an incoming (C2S or S2S) note object carries the flag. */
+export function isHelpRequest(object) {
+  return !!object && (object['shaer:helpRequest'] === true || object.helpRequest === true);
+}
+
+export default { helpRequestProps, isHelpRequest };
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/queues.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,49 @@
+/**
+ * Guardianship (FEP-633c) — the owner-only dashboard queues.
+ *
+ * Three OrderedCollections on the actor (shaer:queues), same contract as the
+ * Shaer test daemon so the iOS/Android guardian dashboards read them as-is:
+ *  - offers:  pending guardianship offers where I am a party (§3)
+ *  - follows: pending follows for my wards (§5.3) — Klonkt has no gated
+ *             follows yet, so this collection is empty for now
+ *  - wards:   my wards, for the dashboard's wards list
+ */
+import { GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
+import * as relations from './relations.js';
+
+const collection = (id, items) => ({
+  id, type: 'OrderedCollection', totalItems: items.length, orderedItems: items,
+});
+
+/** Pending offers, reconstructed as Offer activities (either side). */
+export function offersCollection(id, slug, me) {
+  const items = relations.listOffers(slug).map((r) => ({
+    id: r.offer_id || undefined,
+    type: 'Offer',
+    actor: r.role === 'guardian' ? me : r.other_uri,
+    object: {
+      type: 'Relationship',
+      subject: r.role === 'guardian' ? r.other_uri : me,
+      relationship: GUARDIAN_RELATIONSHIP_COMPACT,
+      object: r.role === 'guardian' ? me : r.other_uri,
+    },
+    'shaer:handle': r.other_handle || undefined,
+    published: r.created_at,
+  }));
+  return collection(id, items);
+}
+
+/** Gated follows awaiting guardian approval — not built in Klonkt yet. */
+export function followsCollection(id) {
+  return collection(id, []);
+}
+
+/** The guardian's wards (accepted), with cached handle for display. */
+export function wardsCollection(id, slug) {
+  const items = relations.listWards(slug)
+    .filter((r) => r.status === 'accepted')
+    .map((r) => ({ id: r.other_uri, 'shaer:handle': r.other_handle || undefined, since: r.created_at }));
+  return collection(id, items);
+}
+
+export default { offersCollection, followsCollection, wardsCollection };
Index: src/services/guardianship/relations.js
===================================================================
--- src/services/guardianship/relations.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
+++ src/services/guardianship/relations.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -0,0 +1,104 @@
+/**
+ * Guardianship (FEP-633c) — the ward ↔ guardian relations (ap_guardianships).
+ *
+ * Every row is one relation seen from a LOCAL site: role 'guardian' means the
+ * site guards `other_uri` (a ward, possibly remote); role 'ward' means
+ * `other_uri` guards the site. A local ward with a local guardian yields two
+ * rows, one per perspective — intentional, each side reads its own.
+ *
+ * The handshake (spec §3): the guardian-candidate — and only the candidate —
+ * Offers a Relationship {subject: ward, relationship: shaer:Guardian,
+ * object: candidate}; the ward Accepts (or Rejects). Status walks
+ * 'offered' → 'accepted'; a Reject deletes the row.
+ */
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
+                       VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)`),
+      accept: db.prepare(`UPDATE ap_guardianships SET status='accepted' WHERE slug=? AND role=? AND other_uri=?`),
+      del: db.prepare('DELETE FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
+      bySlugRole: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? ORDER BY created_at DESC'),
+      one: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
+      byOffer: db.prepare('SELECT * FROM ap_guardianships WHERE offer_id=?'),
+    };
+  }
+  return _s;
+}
+
+// ── Reads ────────────────────────────────────────────────────────────────
+
+/** Accepted guardian URIs of a local ward (feeds shaer:guardians). */
+export function listGuardians(slug) {
+  return stmts().bySlugRole.all(slug, 'ward').filter((r) => r.status === 'accepted');
+}
+
+/** All ward relations of a local guardian (accepted + pending offers). */
+export function listWards(slug) {
+  return stmts().bySlugRole.all(slug, 'guardian');
+}
+
+/** Pending offers where the local site is a party (either side). */
+export function listOffers(slug) {
+  return [...stmts().bySlugRole.all(slug, 'guardian'), ...stmts().bySlugRole.all(slug, 'ward')]
+    .filter((r) => r.status === 'offered');
+}
+
+/** A site is a guardian once it stands in any guardian-side relation. */
+export function isGuardian(slug) {
+  return stmts().bySlugRole.all(slug, 'guardian').length > 0;
+}
+
+export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
+export function findByOfferId(offerId) { return offerId ? stmts().byOffer.all(offerId) : []; }
+
+// ── Writes (the handshake walks through these) ───────────────────────────
+
+/** Record an outgoing/incoming Offer on the local side with `role`. */
+export function recordOffer(slug, role, otherUri, { handle = null, offerId = null } = {}) {
+  stmts().ins.run(slug, role, otherUri, handle, 'offered', offerId);
+  return stmts().one.get(slug, role, otherUri);
+}
+
+/** The ward said yes (or our own offer was accepted): relation becomes real. */
+export function acceptRelation(slug, role, otherUri) {
+  stmts().accept.run(slug, role, otherUri);
+  return stmts().one.get(slug, role, otherUri);
+}
+
+/** Reject / retract / end a relation: the row disappears. */
+export function removeRelation(slug, role, otherUri) {
+  stmts().del.run(slug, role, otherUri);
+  return { ok: true };
+}
+
+// ── Actor document (FEP-633c §2) ─────────────────────────────────────────
+
+/**
+ * The guardianship properties for a local actor doc. `id` is the actor URI.
+ * - shaer:guardians: accepted guardians of this ward (omitted when none)
+ * - shaer:isGuardian: true once the site guards anyone
+ * - shaer:queues: the owner-only dashboard collections (always advertised,
+ *   like `blocked`: clients discover, the routes enforce auth)
+ */
+export function actorProps(id, slug) {
+  const props = {
+    'shaer:queues': {
+      offers: `${id}/queues/offers`,
+      follows: `${id}/queues/follows`,
+      wards: `${id}/queues/wards`,
+    },
+  };
+  const guardians = listGuardians(slug).map((r) => r.other_uri);
+  if (guardians.length) props['shaer:guardians'] = guardians;
+  if (isGuardian(slug)) props['shaer:isGuardian'] = true;
+  return props;
+}
+
+export default {
+  listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
+  recordOffer, acceptRelation, removeRelation, actorProps,
+};
Index: test/activitypub-as2.test.js
===================================================================
--- test/activitypub-as2.test.js	(revision 9a00f283dea4b5647c585540cdef7d52ced93298)
+++ test/activitypub-as2.test.js	(revision 6b5d7da762e4f4cedbefb24fb218a1006897f071)
@@ -32,4 +32,7 @@
   // ActivityPub §5.6: the private blocked collection (owner-only GET).
   'blocked',
+  // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the
+  // sub-keys are the daemon-contract collection names the Shaer clients read.
+  'shaer:queues', 'offers', 'follows', 'wards',
   // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
   'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
