Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision 69bd7471bb6b8a573c6a17fe0d93eafa9cbf6cd7)
+++ src/services/guardianship/handshake.js	(revision 30d0e2cad3257e201abc243a96cf44c6e23237ed)
@@ -545,7 +545,27 @@
 }
 
+/**
+ * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
+ * candidate could not be read.
+ *
+ * Waiting for a further activity from a party is not enough: the commit is
+ * triggered by the LAST `Accept`, so if that one has already arrived nothing
+ * will ever poke it again and the handshake would sit until its window closed.
+ * The ward's dashboard polling its own offers queue is this instance's
+ * schedule, exactly as a read settles a lapse (§3.6.3).
+ *
+ * Deliberately not awaited by the read: a poll should render what is true now,
+ * not block on someone else's slow server. A retry that succeeds shows up in
+ * the next poll, which is the same second or two later.
+ */
+export async function retryDeferred(slug) {
+  for (const o of offers.listDeferred(slug)) {
+    await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
+  }
+}
+
 function notify(slug, ev) {
   try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
 }
 
-export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
+export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred };
Index: src/services/guardianship/offers.js
===================================================================
--- src/services/guardianship/offers.js	(revision 69bd7471bb6b8a573c6a17fe0d93eafa9cbf6cd7)
+++ src/services/guardianship/offers.js	(revision 30d0e2cad3257e201abc243a96cf44c6e23237ed)
@@ -79,7 +79,58 @@
 }
 
-/** 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)) : [];
+/**
+ * How long a guardianship handshake stays open (§3.5). Adding a guardian is a
+ * reversible decision, but not a quick one: the ward, the candidate and every
+ * existing guardian have to answer, and they are people, sometimes on holiday.
+ * A week is long enough that nobody is rushed and short enough that a forgotten
+ * offer does not sit in a child's queue for a month looking like a live choice.
+ */
+export const OFFER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
+
+/** SQLite writes CURRENT_TIMESTAMP as UTC 'YYYY-MM-DD HH:MM:SS', which
+ *  Date.parse reads as LOCAL time — hours out, and enough to expire an offer
+ *  early or late. Same correction as ActivityPubService.isoStamp. */
+const stampMs = (v) => {
+  const s = String(v || '');
+  return Date.parse(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
+};
+
+export const closesAt = (o) => stampMs(o.created_at) + OFFER_WINDOW_MS;
+
+/**
+ * §3.5 fails closed: once the window has run, a handshake that never completed
+ * is over. WHICH failure it was matters (§4.2), so the two get different
+ * terminal states and neither of them is `void`:
+ *
+ *   'expired'     — the parties never all answered. Nothing to say about anyone.
+ *   'unverified'  — everyone answered; the candidate could never be read, so
+ *                   the check never got to run. The parties MUST be told this
+ *                   and MUST NOT be told the candidate was refused. It was not:
+ *                   nobody ever managed to look.
+ */
+export function expireIfDue(slug, offerId, now = Date.now()) {
+  const o = stmts().getOffer.get(slug, offerId);
+  if (!o || o.status !== 'pending') return null;
+  const due = closesAt(o);
+  if (!Number.isFinite(due) || due > now) return null;
+  const status = readyToCommit(o) ? 'unverified' : 'expired';
+  stmts().setStatus.run(status, null, slug, offerId);
+  return { ...o, status };
+}
+
+/** Pending offers where `me` is a party — the offers queue (daemon shape).
+ *  Reads are where lazy completion happens, as with the lapses (§3.6.3): a
+ *  closed window is settled here rather than by a sweeper nobody runs. */
+export function listForParty(slug, me, now = Date.now()) {
+  if (!stmts().listBySlug.get) return [];
+  for (const o of stmts().listBySlug.all(slug)) expireIfDue(slug, o.offer_id, now);
+  return stmts().listBySlug.all(slug).filter((o) => isParty(o, me));
+}
+
+/** Handshakes whose tally is complete but which are not committed: the §4.2
+ *  deferred set, waiting on a candidate nobody could dereference. */
+export function listDeferred(slug) {
+  if (!stmts().listBySlug.get) return [];
+  return stmts().listBySlug.all(slug).filter((o) => readyToCommit(o));
 }
 
@@ -109,3 +160,4 @@
   start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
   readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
+  OFFER_WINDOW_MS, closesAt, expireIfDue, listDeferred,
 };
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision 69bd7471bb6b8a573c6a17fe0d93eafa9cbf6cd7)
+++ src/services/guardianship/queues.js	(revision 30d0e2cad3257e201abc243a96cf44c6e23237ed)
@@ -12,4 +12,5 @@
 import * as relations from './relations.js';
 import * as availability from './availability.js';
+import * as handshake from './handshake.js';
 
 const collection = (id, items) => ({
@@ -22,4 +23,10 @@
  *  clients render both without a second fetch. */
 export function offersCollection(id, slug, me) {
+  // §4.2: a handshake whose candidate could not be dereferenced is deferred,
+  // not decided, and the last Accept may already have landed — so nothing else
+  // would ever retry it. This poll is the schedule. Not awaited: the read
+  // answers with what is true now, and a retry that succeeds surfaces in the
+  // next one. `listForParty` settles closed windows on the way past.
+  handshake.retryDeferred(slug).catch(() => { /* the next read tries again */ });
   const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
   items.push(...availability.lapseQueueItems(slug, me, Date.now()));
