Index: .claude/launch.json
===================================================================
--- .claude/launch.json	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
+++ .claude/launch.json	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -0,0 +1,11 @@
+{
+  "version": "0.0.1",
+  "configurations": [
+    {
+      "name": "klonkt",
+      "runtimeExecutable": "bash",
+      "runtimeArgs": ["-lc", "PORT=4020 PUBLIC_BASE_URL=http://localhost:4020 exec $HOME/.local/node/bin/node src/server.js"],
+      "port": 4020
+    }
+  ]
+}
Index: src/assets/css/guardian.css
===================================================================
--- src/assets/css/guardian.css	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/assets/css/guardian.css	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -67,4 +67,5 @@
 .tag.wait { background: #3a2f1a; color: #e8b04b; }
 .tag.ok { background: #17301f; color: var(--ok); }
+.tag.co { background: #2a1f3a; color: #c39bff; }
 
 #adopt-form { display: flex; gap: 8px; }
Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/assets/js/guardian.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -47,21 +47,45 @@
   }
 
-  // ── 3. Pending offers I sent ───────────────────────────────────────────
+  // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
+  function answer(offerId, decision, btn) {
+    if (btn) btn.disabled = true;
+    fetch('/guardian/offer', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
+    }).then(refresh);
+  }
+  function offerCard(o) {
+    var card = el('div', 'g-card');
+    var row = el('div', 'row');
+    var subject = o['shaer:iAmCandidate']
+      ? handleOf(o['shaer:ward'], o['shaer:wardHandle'])            // my sent offer: about the ward
+      : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
+    row.appendChild(el('span', 'who grow', subject));
+    if (o['shaer:iAmCandidate']) {
+      // My own offer, waiting for the others to accept.
+      row.appendChild(el('span', 'tag wait', T.pending));
+      var rt = el('button', 'quiet small', T.retract);
+      rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
+      row.appendChild(rt);
+    } else if (o['shaer:needsMyAccept']) {
+      // A co-guardianship offer for a ward I already guard: my call.
+      row.appendChild(el('span', 'tag co', T.coguard));
+      var ac = el('button', 'small', T.accept);
+      ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
+      var rj = el('button', 'quiet small', T.reject);
+      rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
+      row.appendChild(ac); row.appendChild(rj);
+    } else {
+      row.appendChild(el('span', 'tag wait', T.awaiting_others));
+    }
+    card.appendChild(row);
+    return card;
+  }
   function renderPending() {
     var list = document.getElementById('pending-list');
     list.textContent = '';
-    var pend = S.pendingOffers || [];
-    pend.forEach(function (w) {
-      var card = el('div', 'g-card');
-      var row = el('div', 'row');
-      row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
-      row.appendChild(el('span', 'tag wait', T.pending));
-      var btn = el('button', 'quiet small', T.retract);
-      btn.addEventListener('click', function () { remove(w.other_uri, btn); });
-      row.appendChild(btn);
-      card.appendChild(row);
-      list.appendChild(card);
-    });
-    show('pending-section', pend.length > 0);
+    var offers = S.offers || [];
+    offers.forEach(function (o) { list.appendChild(offerCard(o)); });
+    show('pending-section', offers.length > 0);
   }
 
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/config/database.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -405,4 +405,8 @@
     );
     CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
+    -- Committed guardian ↔ ward relations, one row per local side. role
+    -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
+    -- slug guards other_uri. status is always 'accepted' here now: PENDING
+    -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
     CREATE TABLE IF NOT EXISTS ap_guardianships (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -411,5 +415,5 @@
       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'
+      status TEXT NOT NULL,        -- 'offered' (legacy) | 'accepted'
       offer_id TEXT,               -- the Offer activity id (FEP-633c section 3)
       created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -417,4 +421,30 @@
     );
     CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
+    -- The multi-party handshake (FEP-633c section 3), one row per offer this
+    -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
+    -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
+    -- only when the candidate returns the handle after ward + candidate + at
+    -- least one existing guardian have accepted.
+    CREATE TABLE IF NOT EXISTS ap_guardian_offers (
+      offer_id TEXT NOT NULL,      -- the Offer activity id (minted by the candidate)
+      slug TEXT NOT NULL,          -- the local site tracking this handshake (each party keeps its own copy)
+      ward_uri TEXT NOT NULL,      -- the ward-to-be
+      candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
+      existing_guardians TEXT NOT NULL DEFAULT '[]',  -- JSON array of the ward's current guardian URIs
+      status TEXT NOT NULL DEFAULT 'pending',         -- 'pending' | 'committed' | 'void'
+      handle TEXT,                 -- the escalation handle returned at commit (section 6)
+      ward_handle TEXT,            -- cached @ward@host for display
+      candidate_handle TEXT,       -- cached @candidate@host for display
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      PRIMARY KEY (slug, offer_id)
+    );
+    CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
+    CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
+      offer_id TEXT NOT NULL,      -- FK to ap_guardian_offers
+      slug TEXT NOT NULL,          -- the local site's copy of the tally
+      party_uri TEXT NOT NULL,     -- the party who accepted (ward | candidate | an existing guardian)
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      PRIMARY KEY (slug, offer_id, party_uri)
+    );
     CREATE TABLE IF NOT EXISTS ap_delivery (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/routes/guardian.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -32,10 +32,12 @@
 function uiStrings(L) {
   const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
-    'pending', 'active', 'retract', 'release', 'open', 'push_unavailable'];
+    'pending', 'active', 'retract', 'release', 'open', 'push_unavailable',
+    'accept', 'reject', 'complete', 'awaiting_others', 'coguard'];
   return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
 }
 
 function dashboardState(site, L) {
-  const wards = Guardianship.listWards(site.slug);
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const me = AP.actorId(base, site.slug);
   const help = db.prepare(
     `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
@@ -44,6 +46,7 @@
   return {
     site: site.slug,
-    wards: wards.filter((w) => w.status === 'accepted'),
-    pendingOffers: wards.filter((w) => w.status === 'offered'),
+    me,
+    wards: Guardianship.listWards(site.slug),               // committed wards
+    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
     help,
     strings: uiStrings(L),
@@ -94,5 +97,19 @@
 });
 
-// ── Manage: retract a pending offer / release a ward ─────────────────────
+// ── Answer an offer (co-guardian accept/reject, or the candidate's final
+//    "complete"). All three are a C2S Accept/Reject on the offer id; the
+//    handshake module decides when it commits (§3.1).
+router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const offerId = String(req.body?.offer || '').trim();
+  const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
+  if (!offerId) return res.status(400).json({ error: 'empty_offer' });
+  const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
+  if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
+  res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
+});
+
+// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
 router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
   const site = siteForUser(req);
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/routes/posts.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -931,11 +931,12 @@
     return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
   }
-  // FEP-633c: pending guardianship offers TO this account (ward side) show
-  // as a special message with an accept button (Robins besluit: the kid
-  // answers in their own Klonkt; safety is handled out-of-band by the
-  // guardians themselves).
-  const guardianOffers = site
-    ? Guardianship.listOffers(site.slug).filter((o) => o.role === 'ward')
-    : [];
+  // FEP-633c: pending guardianship offers TO this account (I am the ward)
+  // show as a special message with an accept button (Robins besluit: the kid
+  // answers in its own Klonkt; safety is out-of-band by the guardians).
+  const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
+  const guardianOffers = (site
+    ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
+    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
   renderPage(req, res, 'pages/messages', {
     pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
@@ -951,15 +952,10 @@
   const back = `${res.locals.siteUrlBase || ''}/messages`;
   const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
-  const guardian = String(req.body.guardian || '').trim();
-  if (!site || !answer || !guardian) return res.redirect(back + '?error=guardianship');
-  const row = Guardianship.getRelation(site.slug, 'ward', guardian);
-  if (!row || row.status !== 'offered') return res.redirect(back + '?error=guardianship');
+  const offer = String(req.body.offer || '').trim();
+  if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
   try {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    const me = ActivityPubService.actorId(base, site.slug);
-    const r = await Guardianship.handleGuardianshipOutbox(site, {
-      type: answer,
-      object: row.offer_id || { type: 'Relationship', subject: me, relationship: 'shaer:Guardian', object: guardian },
-    });
+    // Same C2S Accept/Reject the apps use; the handshake module records the
+    // ward's accept and (once the candidate returns the handle) commits.
+    const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
     if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
   } catch { /* fall through */ }
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/services/ActivityPubService.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -1320,24 +1320,22 @@
   // 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') {
+    // Every LOCAL party this activity is addressed to gets its own copy of the
+    // handshake (a ward and a co-guardian may both live here). Gather candidate
+    // local slugs from the inbox owner, the `to` list, and the ward.
+    const cand = new Set();
+    if (slugParam) cand.add(slugParam);
+    for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
+      if (typeof t === 'string') { const s = slugFromActorUrl(t); if (s) cand.add(s); }
+    }
+    if (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;
-      }
-    }
+      if (rel) { const s = slugFromActorUrl(rel.ward); if (s) cand.add(s); }
+    }
+    let consumed = false;
+    for (const slug of cand) {
+      const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+      if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
+    }
+    if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
   }
 
@@ -3156,19 +3154,31 @@
   buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
 });
+// Which local site (if any) hosts this actor URI — used by the handshake to
+// apply the local side of a commit and to derive a ward's existing guardians.
+function localSlugOf(actorUri) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
+  const slug = slugFromActorUrl(actorUri);
+  if (!slug) return null;
+  try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
+  catch { return null; }
+}
 Guardianship.wireHandshake({
   selfId: selfActorId,
+  localSlug: localSlugOf,
   deliverTo: deliverToActor,
   deriveHandle,
-  // Guardian PWA push: an offer or an answer lands as a notification.
+  fetchActor,
+  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
+  // own Berichten; an existing guardian and a commit land in the PWA.
   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'],
+      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
+      offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
+      committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
     }[ev.kind];
     if (!texts) return;
     const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?';
-    // An offer is answered in the kid's own Berichten; a ward's accept lands
-    // in the guardian's PWA.
     const url = ev.kind === 'offer_received' ? `${pushPrefix(slug)}/messages` : '/guardian';
     pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/services/guardianship/handshake.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -1,16 +1,21 @@
 /**
- * Guardianship (FEP-633c §3) — the adoption handshake.
+ * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
+ * distributed across instances.
  *
- * 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.
+ * The candidate Offers a Relationship{subject: ward, object: candidate},
+ * addressed to the ward AND every existing guardian of the ward. Each party
+ * (ward, existing guardians, and finally the candidate) Accepts, addressed to
+ * all the others, so every instance's copy of the tally converges. The
+ * candidate's Accept is the LAST one and carries the escalation handle in
+ * `result`: that return is the atomic commit (§3.1.3). Only then does the
+ * ward gain the guardian in shaer:guardians and the guardian gain the ward.
+ * A single Reject from any party voids the offer (§3.2).
  *
- * 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.
+ * The state machine lives in offers.js (a faithful port of the Shaer test
+ * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
+ * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
  */
 import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
+import * as offers from './offers.js';
 import * as relations from './relations.js';
 
@@ -19,4 +24,5 @@
 
 const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
+const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
 
 /** Parse a Relationship object into {ward, candidate} or null. */
@@ -31,102 +37,144 @@
 }
 
-// ── C2S: the local account acts (PWA or Shaer app, via the outbox) ────────
+/** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */
+async function existingGuardiansOf(wardUri) {
+  const local = deps.localSlug(wardUri);
+  if (local) return relations.listGuardians(local).map((r) => r.other_uri);
+  const doc = await deps.fetchActor(wardUri).catch(() => null);
+  const g = doc && doc['shaer:guardians'];
+  return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
+}
+
+function offerActivity(offerId, ward, candidate, recipients) {
+  return {
+    id: offerId, type: 'Offer', actor: candidate, to: recipients,
+    object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
+  };
+}
+
+/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
+async function fanout(site, recipients, activity) {
+  let anyDelivered = false;
+  for (const uri of [...new Set(recipients)]) {
+    const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
+    if (r && r.delivered !== false) anyDelivered = true;
+  }
+  return anyDelivered;
+}
+
+/** Apply the local side of a commit: the ward writes its guardian, the
+ *  candidate writes its ward. Each instance writes only what it hosts. */
+function applyCommitLocally(offer, handle) {
+  const wardSlug = deps.localSlug(offer.ward_uri);
+  const candSlug = deps.localSlug(offer.candidate_uri);
+  if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle, offerId: offer.offer_id });
+  if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle, offerId: offer.offer_id });
+}
+
+/** Commit this local copy of the offer when the tally is complete (ward +
+ *  candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
+ *  inbox (§6 minimum); the commit is order-independent, so whichever accept
+ *  lands last triggers it on every copy. */
+function maybeCommit(slug, offerId) {
+  const offer = offers.getOffer(slug, offerId);
+  if (!offer || !offers.readyToCommit(offer)) return null;
+  const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
+  if (done) { applyCommitLocally(done, done.handle); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
+  return done;
+}
+
+// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app 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.
+ * Handle a guardianship activity POSTed to the local outbox. Returns null when
+ * it is not ours, 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);
+  const me = deps.selfId(site.slug);
 
+  // ── Offer: the local site is the guardian-candidate. ───────────────────
   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 });
-    // The offer is now recorded (the guardian sees it as pending); delivery is
-    // async + retried, so a slow ward server never fails the whole action.
-    const res = await deliverTo(site, rel.ward, offer).catch(() => ({ delivered: false }));
+    if (!rel) return null;
+    if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' };   // fixed initiator (§3.1)
+    if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' };  // §1
+    const existing = await existingGuardiansOf(rel.ward);
+    const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
+    offers.start(site.slug, {
+      offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
+      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
+    });
+    // Addressed to the ward AND every existing guardian (§3.1.1).
+    const recipients = [rel.ward, ...existing];
+    const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
     notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
-    return { status: 202, id: offerId, url: offerId, delivered: res && res.delivered !== false };
+    return { status: 202, id: offerId, url: offerId, delivered };
   }
 
-  // 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' };
+  // ── Accept / Reject: the local site is a party answering an offer. ─────
+  const offerId = idOf(activity.object);
+  if (!offerId) return { status: 400, error: 'missing_offer' };
+  let offer = offers.getOffer(site.slug, offerId);
+  if (!offer) return { status: 404, error: 'no_such_offer' };
+  const others = offers.parties(offer).filter((p) => p !== me);
 
-  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);
+  if (type === 'Reject') {
+    offers.recordReject(site.slug, offerId, me);
+    await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
+    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
+    return { status: 202, id: offerId, url: offerId };
   }
-  // The answer is committed locally; delivery is async + retried.
-  const res = await deliverTo(site, row.other_uri, answer).catch(() => ({ delivered: false }));
-  notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri });
-  return { status: 202, id: answer.id, url: answer.id, delivered: res && res.delivered !== false };
+
+  // Accept: record my accept, broadcast it to the other parties, and commit
+  // this copy if the tally is now complete (order-independent, §3.1.3).
+  offers.recordAccept(site.slug, offerId, me);
+  await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
+  const done = maybeCommit(site.slug, offerId);
+  return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
 }
 
-// ── S2S: a remote party acts (arrives in the local inbox) ────────────────
+// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
 
 /**
- * Handle an inbound guardianship activity for local site `site`. Returns
- * true when consumed (the generic inbox skips it), false otherwise.
+ * Handle an inbound guardianship activity for the local site `site` (the inbox
+ * owner). Returns true when consumed.
  */
 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 me = deps.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 });
+    if (!rel) return false;
+    // I must be a party: the ward, or one of the existing guardians in `to`.
+    const recipients = arr(activity.to);
+    const existing = recipients.filter((u) => u !== rel.ward);
+    if (rel.ward !== me && !existing.includes(me)) return false;
+    offers.start(site.slug, {
+      offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
+      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
+    });
+    notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, 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;
+  // Accept / Reject of an offer we (also) track.
+  const offerId = idOf(activity.object);
+  let offer = offers.getOffer(site.slug, offerId);
+  if (!offer) return false;
+  if (!offers.isParty(offer, actor)) 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 });
+  if (type === 'Reject') {
+    offers.recordReject(site.slug, offerId, actor);
+    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
+    return true;
   }
+
+  offers.recordAccept(site.slug, offerId, actor);
+  maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete
   return true;
 }
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/services/guardianship/index.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -4,5 +4,6 @@
  * 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
+ *  - offers.js:    the multi-party handshake state (a port of the Shaer daemon)
+ *  - relations.js: the COMMITTED ward ↔ guardian relations + actor props
  *  - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S
  *  - queues.js:    the owner-only dashboard collections (offers/follows/wards)
@@ -10,9 +11,7 @@
  *  - 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.
+ * The shared blocklist (Shaer's "in Orbit") lives NEXT TO this module in
+ * BlocklistService. ActivityPubService wires the AP helpers in once and
+ * delegates; nothing here imports ActivityPubService back.
  */
 export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
@@ -21,6 +20,7 @@
 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js';
 export { offersCollection, followsCollection, wardsCollection } from './queues.js';
+export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
 export {
-  listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
-  recordOffer, acceptRelation, removeRelation, actorProps as guardianshipActorProps,
+  listGuardians, listWards, isGuardian, getRelation, removeRelation,
+  actorProps as guardianshipActorProps,
 } from './relations.js';
Index: src/services/guardianship/offers.js
===================================================================
--- src/services/guardianship/offers.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
+++ src/services/guardianship/offers.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -0,0 +1,111 @@
+/**
+ * Guardianship (FEP-633c §3) — the multi-party handshake state.
+ *
+ * A faithful port of the Shaer test daemon's `Handshake`, persisted per local
+ * site (so the two implementations behave identically and the clients speak
+ * one contract). One row in ap_guardian_offers per offer this instance is a
+ * party to; the accepts accumulate in ap_guardian_offer_accepts.
+ *
+ * The offer commits only when the guardian-candidate returns the handle
+ * (§3.1.3) after ward + candidate + at least one existing guardian have
+ * accepted (§3.1.2). A single Reject from any party voids it (§3.2). This is
+ * the core safety property: no single party creates a guardianship alone, and
+ * no new guardian is added without an existing guardian's consent.
+ */
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      insOffer: db.prepare(`INSERT OR IGNORE INTO ap_guardian_offers
+        (offer_id, slug, ward_uri, candidate_uri, existing_guardians, status, ward_handle, candidate_handle, created_at)
+        VALUES (?,?,?,?,?, 'pending', ?, ?, CURRENT_TIMESTAMP)`),
+      getOffer: db.prepare('SELECT * FROM ap_guardian_offers WHERE slug=? AND offer_id=?'),
+      offerAnywhere: db.prepare('SELECT * FROM ap_guardian_offers WHERE offer_id=? LIMIT 1'),
+      setStatus: db.prepare('UPDATE ap_guardian_offers SET status=?, handle=COALESCE(?, handle) WHERE slug=? AND offer_id=?'),
+      listBySlug: db.prepare("SELECT * FROM ap_guardian_offers WHERE slug=? AND status='pending' ORDER BY created_at DESC"),
+      insAccept: db.prepare('INSERT OR IGNORE INTO ap_guardian_offer_accepts (offer_id, slug, party_uri, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
+      accepts: db.prepare('SELECT party_uri FROM ap_guardian_offer_accepts WHERE slug=? AND offer_id=?'),
+    };
+  }
+  return _s;
+}
+
+const parties = (o) => [o.ward_uri, o.candidate_uri, ...JSON.parse(o.existing_guardians || '[]')];
+const isParty = (o, actor) => !!actor && parties(o).includes(actor);
+const acceptsOf = (o) => stmts().accepts.all(o.slug, o.offer_id).map((r) => r.party_uri);
+
+/** ward + candidate + (no existing guardians OR at least one existing) accepted. */
+export function readyToCommit(o) {
+  if (!o || o.status !== 'pending') return false;
+  const acc = new Set(acceptsOf(o));
+  const existing = JSON.parse(o.existing_guardians || '[]');
+  const existingOk = existing.length === 0 || existing.some((g) => acc.has(g));
+  return acc.has(o.ward_uri) && acc.has(o.candidate_uri) && existingOk;
+}
+
+/** Start tracking an offer on `slug` (idempotent). */
+export function start(slug, { offerId, ward, candidate, existingGuardians = [], wardHandle = null, candidateHandle = null }) {
+  stmts().insOffer.run(offerId, slug, ward, candidate, JSON.stringify(existingGuardians || []), wardHandle, candidateHandle);
+  return stmts().getOffer.get(slug, offerId);
+}
+
+export function getOffer(slug, offerId) { return stmts().getOffer.get(slug, offerId); }
+export function findOfferAnywhere(offerId) { return stmts().offerAnywhere.get(offerId); }
+
+/** Record an Accept from one party; ignored if not a party or already resolved. */
+export function recordAccept(slug, offerId, party) {
+  const o = stmts().getOffer.get(slug, offerId);
+  if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
+  stmts().insAccept.run(offerId, slug, party);
+  return stmts().getOffer.get(slug, offerId);
+}
+
+/** A single Reject from any party voids the handshake (§3.2). */
+export function recordReject(slug, offerId, party) {
+  const o = stmts().getOffer.get(slug, offerId);
+  if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
+  stmts().setStatus.run('void', null, slug, offerId);
+  return stmts().getOffer.get(slug, offerId);
+}
+
+/** Commit (only when ready): store the returned handle, mark committed. */
+export function commit(slug, offerId, handle) {
+  const o = stmts().getOffer.get(slug, offerId);
+  if (!o || o.status !== 'pending' || !readyToCommit(o)) return null;
+  stmts().setStatus.run('committed', handle || null, slug, offerId);
+  return stmts().getOffer.get(slug, offerId);
+}
+
+/** Pending offers where `me` is a party — the offers queue (daemon shape). */
+export function listForParty(slug, me) {
+  return stmts().listBySlug.get ? stmts().listBySlug.all(slug).filter((o) => isParty(o, me)) : [];
+}
+
+/** One offer as the offers-queue item the Shaer clients parse. */
+export function queueItem(o, me) {
+  const acc = acceptsOf(o).sort();
+  return {
+    id: o.offer_id,
+    type: 'Offer',
+    actor: o.candidate_uri,
+    object: { type: 'Relationship', subject: o.ward_uri, relationship: 'shaer:Guardian', object: o.candidate_uri },
+    'shaer:ward': o.ward_uri,
+    'shaer:candidate': o.candidate_uri,
+    'shaer:existingGuardians': JSON.parse(o.existing_guardians || '[]'),
+    'shaer:acceptedBy': acc,
+    'shaer:needsMyAccept': !acc.includes(me),
+    'shaer:readyToCommit': readyToCommit(o),
+    'shaer:iAmCandidate': me === o.candidate_uri,
+    'shaer:wardHandle': o.ward_handle || undefined,
+    'shaer:candidateHandle': o.candidate_handle || undefined,
+    published: o.created_at,
+  };
+}
+
+export { parties, isParty, acceptsOf };
+export default {
+  start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
+  readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
+};
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/services/guardianship/queues.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -3,11 +3,11 @@
  *
  * 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
+ * Shaer test daemon so the iOS/Android dashboards read them as-is:
+ *  - offers:  pending handshake offers where I am a party (§3), with the full
+ *             accept tally so the client shows the right action
+ *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
+ *  - wards:   my committed wards
  */
-import { GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
+import * as offers from './offers.js';
 import * as relations from './relations.js';
 
@@ -16,47 +16,18 @@
 });
 
-/** Pending offers, reconstructed as Offer activities (either side). Each item
- *  also carries the daemon-contract helper fields (shaer:ward, candidate,
- *  needsMyAccept, iAmCandidate, …): the Shaer clients render their accept
- *  button from those, so the shapes must match the test daemon exactly. */
+/** Pending offers where the local site is a party, each with its accept tally. */
 export function offersCollection(id, slug, me) {
-  const items = relations.listOffers(slug).map((r) => {
-    const ward = r.role === 'guardian' ? r.other_uri : me;
-    const candidate = r.role === 'guardian' ? me : r.other_uri;
-    return {
-      id: r.offer_id || `${me}/offers/pending-${r.id}`,
-      type: 'Offer',
-      actor: candidate,
-      object: {
-        type: 'Relationship',
-        subject: ward,
-        relationship: GUARDIAN_RELATIONSHIP_COMPACT,
-        object: candidate,
-      },
-      'shaer:ward': ward,
-      'shaer:candidate': candidate,
-      'shaer:existingGuardians': relations.listGuardians(slug).map((g) => g.other_uri),
-      'shaer:acceptedBy': [],
-      // Klonkt's flow is single-phase: the ward's Accept commits at once, so
-      // only the ward-side owner has an action here.
-      'shaer:needsMyAccept': r.role === 'ward',
-      'shaer:readyToCommit': false,
-      'shaer:iAmCandidate': r.role === 'guardian',
-      'shaer:handle': r.other_handle || undefined,
-      published: r.created_at,
-    };
-  });
+  const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
   return collection(id, items);
 }
 
-/** Gated follows awaiting guardian approval — not built in Klonkt yet. */
+/** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
 export function followsCollection(id) {
   return collection(id, []);
 }
 
-/** The guardian's wards (accepted), with cached handle for display. */
+/** The guardian's committed wards, 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);
Index: src/services/guardianship/relations.js
===================================================================
--- src/services/guardianship/relations.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/services/guardianship/relations.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -1,14 +1,8 @@
 /**
- * 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.
+ * Guardianship (FEP-633c) — the COMMITTED ward ↔ guardian relations
+ * (ap_guardianships). Pending offers live in offers.js; a row here means the
+ * handshake committed (§3.1.4). Every row is one relation seen from a LOCAL
+ * site: role 'guardian' = the site guards other_uri; role 'ward' = other_uri
+ * guards the site.
  */
 import db from '../../config/database.js';
@@ -18,11 +12,10 @@
   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=?`),
+      commit: db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
+                          VALUES (?,?,?,?, 'accepted', ?, CURRENT_TIMESTAMP)
+                          ON CONFLICT(slug, role, other_uri) DO UPDATE SET status='accepted', offer_id=excluded.offer_id`),
       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'),
+      bySlugRole: db.prepare("SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND status='accepted' 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=?'),
     };
   }
@@ -33,42 +26,29 @@
 
 /** 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');
+export function listGuardians(slug) { return stmts().bySlugRole.all(slug, 'ward'); }
+
+/** Accepted wards of a local guardian (the wards queue). */
+export function listWards(slug) { return stmts().bySlugRole.all(slug, 'guardian'); }
+
+/** A site is a guardian once it stands in any accepted guardian relation. */
+export function isGuardian(slug) { return listWards(slug).length > 0; }
+
+export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
+
+// ── Writes (only the handshake commit lands here) ────────────────────────
+
+/** The local ward gains a guardian (commit, §3.1.4). */
+export function commitGuardianForWard(wardSlug, guardianUri, { handle = null, offerId = null } = {}) {
+  stmts().commit.run(wardSlug, 'ward', guardianUri, handle, offerId);
+  return stmts().one.get(wardSlug, 'ward', guardianUri);
 }
 
-/** All ward relations of a local guardian (accepted + pending offers). */
-export function listWards(slug) {
-  return stmts().bySlugRole.all(slug, 'guardian');
+/** The local guardian gains a ward (commit, §3.1.4). */
+export function commitWardForGuardian(guardianSlug, wardUri, { handle = null, offerId = null } = {}) {
+  stmts().commit.run(guardianSlug, 'guardian', wardUri, handle, offerId);
+  return stmts().one.get(guardianSlug, 'guardian', wardUri);
 }
 
-/** 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. */
+/** End a relation locally (Undo, §3.2 — federation of the Undo is Fase 4). */
 export function removeRelation(slug, role, otherUri) {
   stmts().del.run(slug, role, otherUri);
@@ -79,9 +59,12 @@
 
 /**
- * The guardianship properties for a local actor doc. `id` is the actor URI.
- * - shaer:guardians: accepted guardians of this ward (omitted when none)
+ * Guardianship props for a local actor doc. `id` is the actor URI.
+ * - shaer:guardians: accepted guardians of this ward (omitted when none, §2.1)
  * - 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)
+ * - shaer:queues: the owner-only dashboard collections
+ *
+ * §1 mutual exclusion: a ward (has guardians) is never a guardian, so
+ * shaer:isGuardian is suppressed if guardians exist; the offer path already
+ * bars a ward from offering.
  */
 export function actorProps(id, slug) {
@@ -94,11 +77,14 @@
   };
   const guardians = listGuardians(slug).map((r) => r.other_uri);
-  if (guardians.length) props['shaer:guardians'] = guardians;
-  if (isGuardian(slug)) props['shaer:isGuardian'] = true;
+  if (guardians.length) {
+    props['shaer:guardians'] = guardians;   // a ward
+  } else if (isGuardian(slug)) {
+    props['shaer:isGuardian'] = true;        // a guardian (never both, §1)
+  }
   return props;
 }
 
 export default {
-  listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
-  recordOffer, acceptRelation, removeRelation, actorProps,
+  listGuardians, listWards, isGuardian, getRelation,
+  commitGuardianForWard, commitWardForGuardian, removeRelation, actorProps,
 };
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/services/i18n.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -67,5 +67,5 @@
     'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
     'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
-    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.open': 'open', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
+    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
     'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
     'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
@@ -1008,5 +1008,5 @@
     'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
     'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
-    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.open': 'open', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
+    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
     'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
     'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
@@ -1943,5 +1943,5 @@
     'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
     'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
-    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.open': 'öffnen', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
+    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
     'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
     'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
Index: src/views/pages/messages.ejs
===================================================================
--- src/views/pages/messages.ejs	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/views/pages/messages.ejs	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -12,9 +12,9 @@
       <span style="font-size:1.4em;">&#128735;</span>
       <div style="flex:1;min-width:200px;">
-        <strong><%= o.other_handle || o.other_uri %></strong><br>
+        <strong><%= o['shaer:candidateHandle'] || o['shaer:candidate'] %></strong><br>
         <span><%= t('msg.guard_offer') %></span>
       </div>
       <form method="post" action="<%= (typeof moreBase !== 'undefined' ? moreBase : '') %>/messages/guardianship" style="display:flex;gap:8px;">
-        <input type="hidden" name="guardian" value="<%= o.other_uri %>">
+        <input type="hidden" name="offer" value="<%= o.id %>">
         <button class="btn" type="submit" name="answer" value="accept"><%= t('msg.guard_accept') %></button>
         <button class="btn" type="submit" name="answer" value="reject" style="opacity:.7;"><%= t('msg.guard_reject') %></button>
Index: test/guardianship.test.js
===================================================================
--- test/guardianship.test.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ test/guardianship.test.js	(revision 780a7c655af23759b03e276bc56d8be0bc5d44ff)
@@ -1,5 +1,5 @@
-// The guardianship module (FEP-633c): relations, actor props, the adoption
-// handshake and the dashboard queues. Pins the module's public surface so the
-// Shaer clients' contract stays stable.
+// The guardianship module (FEP-633c) — the multi-party handshake (§3).
+// Everyone lives on one in-memory instance here, so the handshake copies all
+// converge locally; that also exercises the "multiple local parties" routing.
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
@@ -14,108 +14,108 @@
 const G = await import('../src/services/guardianship/index.js');
 
+function site(id, slug) {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
+  return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
+}
 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
-db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'parent', 'Parent', 'u1', 1);
-db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s2', 'kid', 'Kid', 'u1', 0);
-const parent = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
-const kid = db.prepare('SELECT * FROM sites WHERE id = ?').get('s2');
-const ME = 'https://test.example/ap/users/parent';
-const KID = 'https://test.example/ap/users/kid';
+const parent = site('s1', 'parent');   // first guardian-candidate
+const kid = site('s2', 'kid');          // ward
+const gran = site('s3', 'gran');        // second guardian-candidate (co-approver later)
+const A = (slug) => `https://test.example/ap/users/${slug}`;
+const [ME, KID, GRAN] = [A('parent'), A('kid'), A('gran')];
 
-// No network in tests: the handshake delivers via this stub.
-const sent = [];
+// No network: the handshake delivers by feeding each activity straight into the
+// inbound handler of every addressed local party (what real S2S would do).
 G.wireHandshake({
-  selfId: (slug) => `https://test.example/ap/users/${slug}`,
-  deliverTo: async (site, uri, activity) => { sent.push({ from: site.slug, to: uri, activity }); return true; },
-  deriveHandle: (uri) => '@' + String(uri).split('/').pop() + '@test.example',
+  selfId: A,
+  localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
+  deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
+  fetchActor: async () => null,
+  deliverTo: async (fromSite, toUri, activity) => {
+    const slug = toUri.split('/').pop();
+    const s = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+    if (s) await G.handleGuardianshipInbox(s, activity);
+    return { delivered: true };
+  },
   onEvent: null,
 });
 
-test('actor doc advertises shaer:queues (and blocked stays)', () => {
-  const actor = AP.buildActor('https://test.example', parent);
-  assert.equal(actor.blocked, `${ME}/blocked`);
-  assert.deepEqual(actor['shaer:queues'], {
-    offers: `${ME}/queues/offers`,
-    follows: `${ME}/queues/follows`,
-    wards: `${ME}/queues/wards`,
+const offerIdFrom = (r) => r.id;
+
+test('first guardian: candidate offers, ward accepts, candidate completes', async () => {
+  const off = await G.handleGuardianshipOutbox(parent, {
+    type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
   });
-  assert.equal(actor['shaer:isGuardian'], undefined);   // no wards yet
+  assert.equal(off.status, 202);
+  const id = offerIdFrom(off);
+
+  // The kid sees the offer and it needs its accept.
+  const kidQ = G.offersCollection(`${KID}/queues/offers`, 'kid', KID).orderedItems;
+  assert.equal(kidQ.length, 1);
+  assert.equal(kidQ[0]['shaer:needsMyAccept'], true);
+  assert.equal(kidQ[0]['shaer:iAmCandidate'], false);
+
+  // Not committed on a lone candidate — the ward has not accepted.
+  assert.deepEqual(G.listGuardians('kid'), []);
+
+  // The kid accepts (C2S from the kid's own Klonkt). Not committed yet: the
+  // candidate must still agree to serve (§3.1.2).
+  await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
+  assert.deepEqual(G.listGuardians('kid'), []);
+  const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems;
+  assert.equal(parentQ[0]['shaer:iAmCandidate'], true);
+  assert.equal(parentQ[0]['shaer:needsMyAccept'], true);   // candidate has not accepted
+
+  // The candidate accepts → tally complete → commit everywhere.
+  const done = await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
+  assert.equal(done.committed, true);
+  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
+  assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
+
+  // The ward actor now names its guardian; parent reads as guardian (§2).
+  assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
+  assert.equal(AP.buildActor('https://test.example', parent)['shaer:isGuardian'], true);
+  // §1 mutual exclusion: the ward is not also a guardian.
+  assert.equal(AP.buildActor('https://test.example', kid)['shaer:isGuardian'], undefined);
 });
 
-test('C2S Offer from the candidate records + delivers (FEP-633c 3)', async () => {
-  const r = await G.handleGuardianshipOutbox(parent, {
-    type: 'Offer',
-    object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
+test('second guardian needs the EXISTING guardian to co-accept (§3.1.2)', async () => {
+  // Gran offers to also guard the kid (who already has parent).
+  const off = await G.handleGuardianshipOutbox(gran, {
+    type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN },
   });
-  assert.equal(r.status, 202);
-  assert.equal(sent.length, 1);
-  assert.equal(sent[0].to, KID);
-  assert.equal(sent[0].activity.type, 'Offer');
-  const wards = G.listWards('parent');
-  assert.equal(wards.length, 1);
-  assert.equal(wards[0].status, 'offered');
-  // The guardian-to-be now reads as guardian; the actor doc follows.
-  const actor = AP.buildActor('https://test.example', parent);
-  assert.equal(actor['shaer:isGuardian'], true);
+  const id = offerIdFrom(off);
+  // The existing guardian (parent) is a party and must accept.
+  const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
+  assert.ok(parentQ, 'parent sees the co-guardianship offer');
+  assert.deepEqual(parentQ['shaer:existingGuardians'], [ME]);
+
+  // Kid accepts, then gran (candidate) accepts — still NOT committed, because
+  // the existing guardian (parent) has not co-accepted (§3.1.2).
+  await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
+  const early = await G.handleGuardianshipOutbox(gran, { type: 'Accept', object: id });
+  assert.equal(early.committed, false);
+  assert.equal(G.listGuardians('kid').length, 1, 'still just the first guardian');
+
+  // The existing guardian co-accepts → tally complete → commit.
+  await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
+  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [GRAN, ME].sort());
 });
 
-test('only the candidate may offer', async () => {
-  const r = await G.handleGuardianshipOutbox(parent, {
-    type: 'Offer',
-    object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: 'https://elders.test/u/x' },
+test('a single Reject from a required party voids the offer (§3.2)', async () => {
+  // parent offers to guard gran (who is free).
+  const off = await G.handleGuardianshipOutbox(parent, {
+    type: 'Offer', object: { type: 'Relationship', subject: GRAN, relationship: 'shaer:Guardian', object: ME },
   });
-  assert.equal(r.status, 403);
+  const id = offerIdFrom(off);
+  await G.handleGuardianshipOutbox(gran, { type: 'Reject', object: id });
+  const q = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
+  assert.equal(q, undefined, 'voided offer leaves the queue');
+  assert.equal(G.listWards('parent').some((w) => w.other_uri === GRAN), false);
 });
 
-test('inbound Offer parks in the ward queue; C2S Accept commits both ends', async () => {
-  // The kid's side receives the offer S2S.
-  const offerId = sent[0].activity.id;
-  const consumed = await G.handleGuardianshipInbox(kid, {
-    id: offerId, type: 'Offer', actor: ME,
-    object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
-  });
-  assert.equal(consumed, true);
-  assert.equal(G.listOffers('kid').length, 1);
-
-  // The kid's offers queue carries the daemon-contract helper fields the
-  // Shaer clients render their accept button from.
-  const kidQ = G.offersCollection(`${KID}/queues/offers`, 'kid', KID);
-  assert.equal(kidQ.totalItems, 1);
-  assert.equal(kidQ.orderedItems[0]['shaer:needsMyAccept'], true);
-  assert.equal(kidQ.orderedItems[0]['shaer:iAmCandidate'], false);
-  assert.equal(kidQ.orderedItems[0]['shaer:ward'], KID);
-  assert.equal(kidQ.orderedItems[0]['shaer:candidate'], ME);
-
-  // The kid accepts over C2S; the answer travels to the guardian.
-  const r = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: offerId });
-  assert.equal(r.status, 202);
-  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
-
-  // The guardian's side hears the Accept S2S and commits.
-  const ok = await G.handleGuardianshipInbox(parent, { type: 'Accept', actor: KID, object: offerId });
-  assert.equal(ok, true);
-  const wards = G.listWards('parent').filter((w) => w.status === 'accepted');
-  assert.deepEqual(wards.map((w) => w.other_uri), [KID]);
-
-  // The ward's actor doc now names its guardian (FEP-633c 2.1).
-  const actor = AP.buildActor('https://test.example', kid);
-  assert.deepEqual(actor['shaer:guardians'], [ME]);
-});
-
-test('queues serve the daemon contract shapes', () => {
-  const wardsQ = G.wardsCollection(`${ME}/queues/wards`, 'parent');
-  assert.equal(wardsQ.type, 'OrderedCollection');
-  assert.equal(wardsQ.totalItems, 1);
-  assert.equal(wardsQ.orderedItems[0].id, KID);
-  const followsQ = G.followsCollection(`${ME}/queues/follows`);
-  assert.deepEqual(followsQ.orderedItems, []);
-  const offersQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME);
-  assert.equal(offersQ.type, 'OrderedCollection');   // empty again after the accept
-  assert.equal(offersQ.totalItems, 0);
-});
-
-test('a ward cannot become a guardian (FEP-633c 1)', async () => {
+test('a ward cannot become a guardian (§1)', async () => {
   const r = await G.handleGuardianshipOutbox(kid, {
-    type: 'Offer',
-    object: { type: 'Relationship', subject: 'https://other.test/u/y', relationship: 'shaer:Guardian', object: KID },
+    type: 'Offer', object: { type: 'Relationship', subject: A('someone'), relationship: 'shaer:Guardian', object: KID },
   });
   assert.equal(r.status, 403);
@@ -123,7 +123,13 @@
 });
 
+test('only the candidate may offer (§3.1 fixed initiator)', async () => {
+  const r = await G.handleGuardianshipOutbox(parent, {
+    type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
+  });
+  assert.equal(r.status, 403);
+  assert.equal(r.error, 'only_the_candidate_offers');
+});
+
 test('helpRequest props only ride direct notes', () => {
-  assert.deepEqual(G.helpRequestProps({ visibility: 'direct', help_request: 1 }), { 'shaer:helpRequest': true });
-  assert.deepEqual(G.helpRequestProps({ visibility: 'public', help_request: 1 }), {});
   assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
   assert.equal(G.isHelpRequest({}), false);
