Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision 04d5aebb050cd1c96020a822d03370dbf5b13399)
+++ src/services/guardianship/handshake.js	(revision 5327324cd028236fac938e6870fe3f6c9366ab1f)
@@ -114,14 +114,70 @@
 }
 
+/**
+ * FEP-633c §4.2 — is this candidate fit to be a guardian at all?
+ *
+ * A guardian MUST be free of guardians (§1). Checked here and not at the Offer,
+ * because guardianship state can change in between: a candidate that was free
+ * when it offered may have been adopted before the ward accepted. So the check
+ * runs against a freshly dereferenced actor document, at the moment the
+ * relationship would become real.
+ *
+ * Three answers, and the third is not a failure of this check but a failure to
+ * perform it:
+ *   'ok'          — free of guardians, may serve
+ *   'malformed'   — carries shaer:guardians; a teapot (§4)
+ *   'unverified'  — the actor could not be read at all
+ */
+async function candidateFitness(candidateUri) {
+  // A candidate on this instance needs no dereference: our own tables are the
+  // document, and fresher than anything we could fetch from ourselves. This is
+  // also the co-located case (ward and guardian on one Klonkt), where there is
+  // no network to be unreachable on.
+  const local = deps.localSlug(candidateUri);
+  if (local) return relations.listGuardians(local).length > 0 ? 'malformed' : 'ok';
+
+  const doc = await deps.fetchActor(candidateUri).catch(() => null);
+  if (!doc) return 'unverified';
+  const g = doc['shaer:guardians'];
+  const has = Array.isArray(g) ? g.length > 0
+    : typeof g === 'string' ? g.length > 0
+      : (g && typeof g === 'object') ? (Array.isArray(g.items) ? g.items.length > 0 : true)
+        : false;
+  return has ? 'malformed' : 'ok';
+}
+
 /** 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) {
+async function maybeCommit(slug, offerId) {
   const offer = offers.getOffer(slug, offerId);
-  if (!offer || !offers.readyToCommit(offer)) return null;
+  if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null };
+
+  const fitness = await candidateFitness(offer.candidate_uri);
+
+  // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A
+  // handshake concerns exactly one candidate, so there is no remaining
+  // well-formed target to continue to; committing anyway would leave the ward
+  // counting a guardian whose escalations get dropped. Voiding is all this
+  // function does; saying so on the wire belongs to whoever was acting.
+  if (fitness === 'malformed') {
+    offers.recordReject(slug, offerId, offer.ward_uri);   // voids this copy (§3.2)
+    notify(slug, {
+      kind: 'offer_rejected', offer: offerId,
+      reason: 'not_a_teapot', candidate: offer.candidate_uri,
+    });
+    return { done: null, refused: 'not_a_teapot' };
+  }
+
+  // Could not read the candidate: neither commit nor void. Refusing outright
+  // would let a momentary outage destroy a multi-party adoption; committing
+  // would record a guardian nobody checked. The offer stays pending and the
+  // next accept retries.
+  if (fitness === 'unverified') return { done: null, refused: null };
+
   const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
   if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
-  return done;
+  return { done, refused: null };
 }
 
@@ -296,5 +352,16 @@
   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);
+  const { done, refused } = await maybeCommit(site.slug, offerId);
+  if (refused) {
+    // §4.2: the refusal travels as a `Reject` of the Offer, from whoever was
+    // about to commit — the same voice that just sent the Accept. A `Reject`
+    // is what §3 already understands, so a server that has never heard of §4
+    // still voids its copy correctly; the marker only adds the reason.
+    await fanout(site, others, {
+      id: `${me}/answers/${Date.now().toString(36)}`,
+      type: 'Reject', actor: me, to: others, object: offerId, 'shaer:notATeapot': true,
+    });
+    return { status: 202, id: offerId, url: offerId, committed: false, refused };
+  }
   return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
 }
@@ -448,5 +515,5 @@
 
   offers.recordAccept(site.slug, offerId, actor);
-  maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete
+  await maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete (§4.2 may refuse)
   return true;
 }
Index: test/co-location.test.js
===================================================================
--- test/co-location.test.js	(revision 04d5aebb050cd1c96020a822d03370dbf5b13399)
+++ test/co-location.test.js	(revision 5327324cd028236fac938e6870fe3f6c9366ab1f)
@@ -214,4 +214,5 @@
   const allowed = {
     existingGuardiansOf: 'reads our own guardian list instead of fetching our own actor doc',
+    candidateFitness: '§4.2: same question, same source — is this candidate a ward? Our table, not a self-fetch',
     applyCommitLocally: '§3.1.4: each instance writes the side of the commit it hosts',
     endGuardianship: '§3.2: same, for the ward side of the Undo, after the fanout',
Index: test/guardianship.test.js
===================================================================
--- test/guardianship.test.js	(revision 04d5aebb050cd1c96020a822d03370dbf5b13399)
+++ test/guardianship.test.js	(revision 5327324cd028236fac938e6870fe3f6c9366ab1f)
@@ -119,4 +119,45 @@
   assert.equal(r.status, 403);
   assert.equal(r.error, 'a_ward_cannot_guard');
+});
+
+test('a candidate adopted between Offer and Accept is refused at commit (§4.2)', async () => {
+  // The case the §1 check above structurally cannot catch. Tess is free when
+  // she offers, so the Offer is legitimate and accepted. Only afterwards does
+  // she become a ward herself. An implementation that checks the candidate
+  // only when the Offer arrives would commit her anyway, and Sam would be left
+  // counting a guardian whose escalations get dropped (§4.1).
+  // Fresh actors throughout: the suite shares one database, so adopting Tess
+  // with an existing guardian would hand that guardian an extra ward and
+  // quietly change the arithmetic of the emancipation tests further down.
+  const tess = site('s10', 'tess');
+  const sam = site('s11', 'sam');
+  const ada = site('s12', 'ada');
+  const [TESS, SAM, ADA] = [A('tess'), A('sam'), A('ada')];
+
+  // 1. Tess offers to guard Sam while she is still free of guardians.
+  const off = await G.handleGuardianshipOutbox(tess, {
+    type: 'Offer', object: { type: 'Relationship', subject: SAM, relationship: 'shaer:Guardian', object: TESS },
+  });
+  assert.equal(off.status, 202, 'a free candidate may offer');
+  const id = off.id;
+  assert.deepEqual(G.listGuardians('sam'), [], 'nothing committed until Sam accepts');
+
+  // 2. Before Sam answers, Tess is adopted: she is now a ward herself.
+  const adopt = await G.handleGuardianshipOutbox(ada, {
+    type: 'Offer', object: { type: 'Relationship', subject: TESS, relationship: 'shaer:Guardian', object: ADA },
+  });
+  await G.handleGuardianshipOutbox(tess, { type: 'Accept', object: adopt.id });
+  assert.equal(G.listGuardians('tess').length, 1, 'Tess is a ward now');
+
+  // 3. Sam accepts. The tally is complete, so this WOULD commit.
+  const done = await G.handleGuardianshipOutbox(sam, { type: 'Accept', object: id });
+  assert.equal(done.committed, false, 'but a ward cannot serve as a guardian (§1)');
+  assert.equal(done.refused, 'not_a_teapot');
+
+  // The refusal is loud, not a silent skip: nothing recorded, offer voided.
+  assert.deepEqual(G.listGuardians('sam'), [], 'Sam gains no guardian');
+  assert.deepEqual(G.listWards('tess').map((w) => w.other_uri), [], 'and Tess gains no ward');
+  const stillPending = G.offersCollection(`${SAM}/queues/offers`, 'sam', SAM).orderedItems.filter((o) => o.id === id);
+  assert.deepEqual(stillPending, [], 'the handshake is void, not left hanging');
 });
 
