Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision c26cc18be55079e31ab7f92b27f94d8de391003e)
+++ src/services/ActivityPubService.js	(revision b5924ebf31cdcc5aa15b62679804833e70054824)
@@ -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 b5924ebf31cdcc5aa15b62679804833e70054824)
@@ -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 b5924ebf31cdcc5aa15b62679804833e70054824)
@@ -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 b5924ebf31cdcc5aa15b62679804833e70054824)
+++ src/services/guardianship/offers.js	(revision b5924ebf31cdcc5aa15b62679804833e70054824)
@@ -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 b5924ebf31cdcc5aa15b62679804833e70054824)
@@ -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 b5924ebf31cdcc5aa15b62679804833e70054824)
@@ -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 b5924ebf31cdcc5aa15b62679804833e70054824)
@@ -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.',
