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 };
