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