Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/config/database.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -523,4 +523,42 @@
       PRIMARY KEY (slug, feature, guardian_uri)
     );
+    -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
+    -- from one ward on this server. Never public; the ward reads it via the
+    -- owner-only guardians queue. One rule above all: one answer restores
+    -- everything, so every row here is one answer away from disappearing.
+    CREATE TABLE IF NOT EXISTS ap_guardian_attention (
+      ward_slug TEXT NOT NULL,
+      guardian_uri TEXT NOT NULL,
+      state TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'away' | 'dormant'
+      away_until INTEGER,                    -- epoch ms while declared away
+      PRIMARY KEY (ward_slug, guardian_uri)
+    );
+    -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
+    -- requests that went unanswered. Calendar time alone never counts.
+    CREATE TABLE IF NOT EXISTS ap_attention_requests (
+      ward_slug TEXT NOT NULL,
+      guardian_uri TEXT NOT NULL,
+      request_id TEXT NOT NULL,
+      asked_at INTEGER NOT NULL,             -- epoch ms
+      PRIMARY KEY (ward_slug, guardian_uri, request_id)
+    );
+    -- A lapse (3.6.3): the available co-guardians deciding to release a
+    -- dormant one. Irreversible, so the window always runs in full; any sign
+    -- of life from the target cancels it outright.
+    CREATE TABLE IF NOT EXISTS ap_lapses (
+      id TEXT PRIMARY KEY,
+      ward_slug TEXT NOT NULL,
+      ward_uri TEXT NOT NULL,
+      target_uri TEXT NOT NULL,
+      opened_by TEXT NOT NULL,
+      set_json TEXT NOT NULL,                -- the available set at open, target excluded
+      accepts_json TEXT NOT NULL DEFAULT '[]',
+      rejects_json TEXT NOT NULL DEFAULT '[]',
+      opened_at INTEGER NOT NULL,            -- epoch ms
+      window_ms INTEGER NOT NULL,
+      cancelled INTEGER NOT NULL DEFAULT 0,
+      applied INTEGER NOT NULL DEFAULT 0,
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
     CREATE TABLE IF NOT EXISTS ap_delivery (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -617,4 +655,5 @@
   ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
   ensureColumn('ap_outbox', 'wave', 'INTEGER');    // FEP-633c shaer:wave (guardian -> ward nudge)
+  ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
   ensureColumn('ap_mentions', 'wave', 'INTEGER');  // inbound guardian wave
   // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/routes/activitypub.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -135,4 +135,7 @@
 queueRoute('follows', (id) => Guardianship.followsCollection(id));
 queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
+// Availability (FEP-633c 3.6.1) is never public: the ward reads its
+// guardians' real states here and nowhere else.
+queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
 
 // ── Inbox read (owner only, AP C2S) ───────────────────────────────
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/routes/guardian.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -210,6 +210,10 @@
   const pending = Guardianship.follows.getPending(req.params.id);
   if (!pending) return res.status(404).json({ error: 'gone' });
-  const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
+  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
+  if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
+  // Acting from the dashboard is an answer (3.6), and the quorum runs over
+  // the available set (3.5): both applied here, the same as over the wire.
+  Guardianship.availability.oneAnswer(me, Date.now());
+  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
   const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
   try {
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/ActivityPubService.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -288,4 +288,5 @@
       ...Guardianship.helpRequestProps(post),
       ...Guardianship.waveProps(post),
+      ...Guardianship.awayProps(post),
       // FEP-633c §2.2: object hint that the author is a ward.
       ...Guardianship.hasGuardiansProps(site.slug),
@@ -1365,4 +1366,14 @@
       return 401;
     }
+    // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
+    // from an actor that guards someone here restores it to active for those
+    // wards and cancels any lapse running against it, before the activity is
+    // even looked at. Signature-gated on purpose: an unverified claim of
+    // being gran must not wake gran up.
+    try {
+      const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
+      if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
+      for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
+    } catch { /* availability is never load-bearing for delivery */ }
   }
 
@@ -1454,4 +1465,10 @@
       const wardKeys = getOrCreateKeys(slug);
       const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
+      // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
+      // every guardian. The ONLY admissible evidence is a request like this
+      // one going unanswered; recordRequest itself skips a declared absence.
+      for (const g of wardGuardians) {
+        try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
+      }
       for (const g of wardGuardians) {
         // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
@@ -1637,4 +1654,19 @@
         const wave = Guardianship.isWave(o);
         const hasG = Guardianship.objectHasGuardians(o);   // §2.2 hint, register-only
+        // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
+        // same direct note the mention below stores (so the kid also reads it
+        // as an ordinary message). Recorded only from an actual guardian of
+        // the addressed ward, and only with an end: an absence without an end
+        // is logged and dropped, never guessed.
+        if (Guardianship.availability.isAway(o)) {
+          const until = Guardianship.availability.parseEndTime(o.endTime);
+          for (const slug of slugs) {
+            const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
+            if (!isG) continue;
+            if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
+            Guardianship.availability.declareAway(slug, actorUri, until);
+            console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
+          }
+        }
         for (const slug of slugs) {
           try {
@@ -2213,5 +2245,24 @@
             .filter(Boolean);
           const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
-          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help });
+          // FEP-633c 3.6.1: a guardian here declaring itself away to its
+          // wards. An away without a (future) end fails loudly, exactly as
+          // the daemon refuses it: stored quietly it would be a nominal
+          // guardian holding a seat.
+          let awayUntil = null;
+          if (Guardianship.availability.isAway(object)) {
+            awayUntil = Guardianship.availability.parseEndTime(object.endTime);
+            if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
+            // A ward we host ourselves never receives its own delivery
+            // (private ranges, loopback): apply locally, the way the
+            // handshake commit does.
+            const meUri = selfActorId(site.slug);
+            for (const uri of recipients) {
+              const wslug = uri.startsWith(`${base}/`) ? slugFromActorUrl(uri) : null;
+              if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === meUri)) {
+                Guardianship.availability.declareAway(wslug, meUri, awayUntil);
+              }
+            }
+          }
+          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil });
           if (!r || !r.id) return { status: 502, error: 'direct_failed' };
           return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
@@ -3439,7 +3490,11 @@
   const pending = Guardianship.follows.getPending(followId);
   if (!pending) return false;
-  const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!guardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
+  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
+  if (!allGuardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
   const decision = type === 'Reject' ? 'reject' : 'approve';
+  // §3.5: the quorum runs over the AVAILABLE set. The voter itself was
+  // restored by the one-answer rule when its activity arrived, so answering
+  // is exactly what counts a guardian back in.
+  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
   const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
   try {
@@ -3784,4 +3839,28 @@
 });
 
+// The notification duty of FEP-633c 3.6.2, wired once for every place a
+// dormancy promotion can happen (queue reads, fan-outs, tallies): marking a
+// guardian dormant MUST notify it, in protocol AND over the §6 handle. The
+// one-answer rule is worthless to someone who does not know an answer is
+// wanted. The handle of a committed guardian is its inbox (§6 minimum), which
+// is the same door this delivery knocks on; both attempts are logged.
+Guardianship.wireAvailability({
+  onDormant: (wardSlug, guardianUri) => {
+    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug);
+    if (!base || !site) return;
+    const me = selfActorId(wardSlug);
+    const note = {
+      id: `${me}/dormant/${Date.now().toString(36)}${rid()}`,
+      type: 'Note', attributedTo: me, to: [guardianUri],
+      'shaer:dormant': true,
+      content: '<p>You have been observed dormant as a guardian. Nothing is wrong and nothing is held against you: one answer restores everything (FEP-633c 3.6.2).</p>',
+    };
+    deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note })
+      .catch(() => { /* retried by the queue */ });
+    console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)');
+  },
+});
+
 export default {
   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
Index: src/services/guardianship/availability.js
===================================================================
--- src/services/guardianship/availability.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
+++ src/services/guardianship/availability.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -0,0 +1,294 @@
+/**
+ * Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
+ *
+ * The port of the Shaer test daemon's availability.rs, validated there first
+ * (shaer-8z7): same states, same rules, same refusals. Guardianship demands
+ * attention; `shaer:guardians` is a public claim about safety, and a guardian
+ * who no longer answers makes it untrue. It also quietly breaks the §3.5
+ * arithmetic: a majority of a set with absent members can be unreachable.
+ *
+ * Three states per (ward, guardian), and one rule above everything else:
+ * ONE ANSWER RESTORES EVERYTHING, at any moment up to and including a
+ * running lapse. Neither away nor dormant is misconduct; neither leaves a
+ * mark.
+ *
+ * Time is always a parameter here, never read from a clock inside the rules,
+ * so a fourteen-day window is a number in a test and not a wait.
+ */
+import db from '../../config/database.js';
+import { listGuardians, removeRelation } from './relations.js';
+
+/** Deployment numbers (§3.6.2 keeps them out of the spec on purpose: any
+ *  number written there would punish exactly the long-term ill). Matched to
+ *  the daemon's defaults so the two backends behave the same under test. */
+export const POLICY = {
+  requestTtlMs: 7 * 24 * 3600 * 1000,   // how long a request may sit unanswered
+  missesForDormant: 3,                   // how many missed requests make dormant
+};
+
+/** The lapse window. Irreversible per §3.5, so it always runs in full. */
+export const LAPSE_WINDOW_MS = 14 * 24 * 3600 * 1000;
+
+/** Marker detection: the away declaration rides a direct note (§2.4). */
+export function isAway(object) {
+  return !!object && (object['shaer:away'] === true || object.away === true);
+}
+
+/** AS2 endTime → epoch ms. A number passes through; a string goes through
+ *  Date.parse (which reads ISO 8601, offsets included). null when absent or
+ *  unreadable: an absence without an end is refused, never guessed. */
+export function parseEndTime(v) {
+  if (typeof v === 'number' && Number.isFinite(v)) return v;
+  if (typeof v !== 'string' || !v.trim()) return null;
+  const t = Date.parse(v);
+  return Number.isNaN(t) ? null : t;
+}
+
+// ── Attention (the per-guardian state) ─────────────────────────────────────
+
+function attentionRow(wardSlug, guardianUri) {
+  return db.prepare('SELECT * FROM ap_guardian_attention WHERE ward_slug = ? AND guardian_uri = ?')
+    .get(wardSlug, guardianUri) || { ward_slug: wardSlug, guardian_uri: guardianUri, state: 'active', away_until: null };
+}
+
+/** What the stored state means at `now`: an away past its end is simply
+ *  active again, silently (§3.6.1). */
+export function effective(wardSlug, guardianUri, now) {
+  const row = attentionRow(wardSlug, guardianUri);
+  if (row.state === 'away') return (row.away_until && now < row.away_until) ? 'away' : 'active';
+  return row.state;
+}
+
+/** The away end, when there is a running one (for display). */
+export function awayUntil(wardSlug, guardianUri, now) {
+  const row = attentionRow(wardSlug, guardianUri);
+  return (row.state === 'away' && row.away_until && now < row.away_until) ? row.away_until : null;
+}
+
+/** Declare absence with an end (§3.6.1). The declaration is itself an
+ *  answer, so it first restores: declaring away while dormant clears the
+ *  dormancy, without a mark. Declaring away is the responsible act. */
+export function declareAway(wardSlug, guardianUri, untilMs) {
+  db.prepare('DELETE FROM ap_attention_requests WHERE ward_slug = ? AND guardian_uri = ?').run(wardSlug, guardianUri);
+  db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
+              VALUES (?,?, 'away', ?)
+              ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'away', away_until = excluded.away_until`)
+    .run(wardSlug, guardianUri, untilMs);
+}
+
+/** A directly addressed request went out to this guardian (a §3.5 decision
+ *  naming them, or an explicit check-in). Requests during a declared absence
+ *  are not recorded: away MUST NOT count as evidence (§3.6.1). */
+export function recordRequest(wardSlug, guardianUri, requestId, now) {
+  if (effective(wardSlug, guardianUri, now) === 'away') return;
+  db.prepare(`INSERT OR IGNORE INTO ap_attention_requests (ward_slug, guardian_uri, request_id, asked_at)
+              VALUES (?,?,?,?)`).run(wardSlug, guardianUri, requestId, now);
+}
+
+/** Missed requests: unanswered ones older than the policy TTL. */
+export function misses(wardSlug, guardianUri, now) {
+  const r = db.prepare(`SELECT COUNT(*) AS n FROM ap_attention_requests
+                        WHERE ward_slug = ? AND guardian_uri = ? AND asked_at <= ?`)
+    .get(wardSlug, guardianUri, now - POLICY.requestTtlMs);
+  return r ? r.n : 0;
+}
+
+/** Promote to dormant when the evidence says so. Returns true only on the
+ *  transition itself: THAT is the moment the notification duty of §3.6.2
+ *  fires (protocol AND the §6 handle), and it is the caller's job — wired
+ *  through onDormant below so every call site notifies the same way. */
+export function observe(wardSlug, guardianUri, now) {
+  if (effective(wardSlug, guardianUri, now) !== 'active') return false;
+  if (misses(wardSlug, guardianUri, now) < POLICY.missesForDormant) return false;
+  db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
+              VALUES (?,?, 'dormant', NULL)
+              ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'dormant', away_until = NULL`)
+    .run(wardSlug, guardianUri);
+  notifyDormant(wardSlug, guardianUri);
+  return true;
+}
+
+/** The notification duty of §3.6.2, wired once (ActivityPubService). The
+ *  one-answer rule is worthless to someone who does not know an answer is
+ *  wanted; the §6 handle exists for precisely this moment. */
+let _onDormant = null;
+export function wireAvailability({ onDormant } = {}) { _onDormant = onDormant || null; }
+function notifyDormant(wardSlug, guardianUri) {
+  try { if (_onDormant) _onDormant(wardSlug, guardianUri); } catch { /* best-effort */ }
+}
+
+/**
+ * One answer restores everything (§3.6). Any activity from an actor that
+ * guards someone on this server restores it to active for those wards and
+ * cancels any lapse running against it, up to the last moment of the window.
+ * Returns what changed, so a caller can log or announce it.
+ */
+export function oneAnswer(guardianUri, now) {
+  if (!guardianUri) return { restored: [], cancelledLapses: [] };
+  const restored = [];
+  for (const row of db.prepare(`SELECT ward_slug, state FROM ap_guardian_attention WHERE guardian_uri = ?`).all(guardianUri)) {
+    if (row.state !== 'active') restored.push(row.ward_slug);
+  }
+  const hadRequests = db.prepare('SELECT DISTINCT ward_slug FROM ap_attention_requests WHERE guardian_uri = ?').all(guardianUri);
+  for (const r of hadRequests) if (!restored.includes(r.ward_slug)) restored.push(r.ward_slug);
+  db.prepare("UPDATE ap_guardian_attention SET state = 'active', away_until = NULL WHERE guardian_uri = ?").run(guardianUri);
+  db.prepare('DELETE FROM ap_attention_requests WHERE guardian_uri = ?').run(guardianUri);
+
+  const cancelledLapses = [];
+  for (const l of db.prepare('SELECT * FROM ap_lapses WHERE target_uri = ? AND cancelled = 0 AND applied = 0').all(guardianUri)) {
+    if (lapseOutcome(l, now) === 'open') {
+      db.prepare('UPDATE ap_lapses SET cancelled = 1 WHERE id = ?').run(l.id);
+      cancelledLapses.push({ id: l.id, wardSlug: l.ward_slug, wardUri: l.ward_uri, set: JSON.parse(l.set_json) });
+    }
+  }
+  return { restored, cancelledLapses };
+}
+
+/** The available set of §3.5: the guardians minus away and dormant members.
+ *  Observation (and thus the dormancy promotion) happens here, so reading the
+ *  set is what moves the clock's consequences. */
+export function availableSet(wardSlug, guardianUris, now) {
+  return guardianUris.filter((g) => {
+    observe(wardSlug, g, now);
+    return effective(wardSlug, g, now) === 'active';
+  });
+}
+
+/** The guardians queue items (§3.6.1: never public, owner-only): the real
+ *  size of the ward's safety net. Same shape the daemon serves. */
+export function statusesFor(wardSlug, guardianUris, now) {
+  return guardianUris.map((g) => {
+    observe(wardSlug, g, now);
+    const running = db.prepare(`SELECT id FROM ap_lapses WHERE ward_slug = ? AND target_uri = ? AND cancelled = 0 AND applied = 0`)
+      .get(wardSlug, g);
+    return {
+      id: g,
+      'shaer:availability': effective(wardSlug, g, now),
+      'shaer:awayUntil': awayUntil(wardSlug, g, now),
+      'shaer:lapse': running && lapseOutcome(db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(running.id), now) === 'open' ? running.id : null,
+    };
+  });
+}
+
+// ── The lapse (§3.6.3): release in absentia ────────────────────────────────
+
+/** Read a shaer:Lapse object, or null when this is a different Offer. */
+export function parseLapse(object) {
+  if (!object || typeof object !== 'object') return null;
+  const type = Array.isArray(object.type) ? object.type[0] : object.type;
+  if (type !== 'shaer:Lapse' && type !== 'Lapse') return null;
+  const ward = object['shaer:ward'] || object.ward;
+  const target = typeof object.object === 'string' ? object.object : (object.object && object.object.id);
+  return (typeof ward === 'string' && typeof target === 'string') ? { ward, target } : null;
+}
+
+/** Strict majority of the set (§3.5 default). */
+export function lapseThreshold(setSize) { return Math.floor(setSize / 2) + 1; }
+
+/** Pure outcome: cancelled beats everything; the window always runs in full
+ *  (§3.5, irreversible), then a strict majority completes, else it fails
+ *  closed. */
+export function lapseOutcome(row, now) {
+  if (!row) return null;
+  if (row.cancelled) return 'cancelled';
+  if (now - row.opened_at < row.window_ms) return 'open';
+  const accepts = JSON.parse(row.accepts_json).length;
+  return accepts >= lapseThreshold(JSON.parse(row.set_json).length) ? 'completed' : 'failed';
+}
+
+/**
+ * Open a lapse on this server (we host the ward). Refusals mirror the
+ * daemon's, status for status:
+ *  - not_a_guardian: the target does not guard this ward
+ *  - would_emancipate: removing the last guardian is §3.4, never a lapse
+ *  - not_dormant: a lapse opens only against a guardian already dormant
+ *  - not_in_available_set: only an available co-guardian proposes
+ */
+export function openLapse({ id, wardSlug, wardUri, target, openedBy, now, windowMs = LAPSE_WINDOW_MS }) {
+  const guardians = listGuardians(wardSlug).map((g) => g.other_uri);
+  if (!guardians.includes(target)) return { error: 'not_a_guardian' };
+  if (guardians.length <= 1) return { error: 'would_emancipate' };
+  observe(wardSlug, target, now);
+  if (effective(wardSlug, target, now) !== 'dormant') return { error: 'not_dormant' };
+  const set = availableSet(wardSlug, guardians, now).filter((g) => g !== target);
+  if (!set.includes(openedBy)) return { error: 'not_in_available_set' };
+  // The proposal carries the proposer's own accept (§3.1's one-step clause,
+  // exactly as §5.6 applies it).
+  db.prepare(`INSERT INTO ap_lapses (id, ward_slug, ward_uri, target_uri, opened_by, set_json, accepts_json, opened_at, window_ms)
+              VALUES (?,?,?,?,?,?,?,?,?)`)
+    .run(id, wardSlug, wardUri, target, openedBy, JSON.stringify(set), JSON.stringify([openedBy]), now, windowMs);
+  return { lapse: db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id), set, threshold: lapseThreshold(set.length) };
+}
+
+/** Record a vote from a set member. Answers from outside the snapshot are
+ *  refused, not counted: a stranger cannot make up the majority. */
+export function lapseVote(id, actor, accept, now) {
+  const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
+  if (!row) return null;
+  const outcome = lapseOutcome(row, now);
+  if (outcome !== 'open') return { error: outcome === 'cancelled' ? 'cancelled' : 'closed' };
+  const set = JSON.parse(row.set_json);
+  if (!set.includes(actor)) return { error: 'not_in_set' };
+  const accepts = new Set(JSON.parse(row.accepts_json));
+  const rejects = new Set(JSON.parse(row.rejects_json));
+  if (accept) { rejects.delete(actor); accepts.add(actor); }
+  else { accepts.delete(actor); rejects.add(actor); }
+  db.prepare('UPDATE ap_lapses SET accepts_json = ?, rejects_json = ? WHERE id = ?')
+    .run(JSON.stringify([...accepts]), JSON.stringify([...rejects]), id);
+  return { outcome: 'open', accepts: accepts.size, threshold: lapseThreshold(set.length) };
+}
+
+/**
+ * Evaluate a lapse at `now`, executing the removal exactly once when the
+ * window has closed with a majority. The refusal to empty shaer:guardians
+ * stands as a second lock under this one: even a completed lapse must not
+ * take the last guardian (that is emancipation, §3.4).
+ */
+export function settleLapse(id, now) {
+  const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
+  if (!row) return null;
+  const outcome = lapseOutcome(row, now);
+  if (outcome !== 'completed' || row.applied) return { outcome, applied: !!row.applied, row };
+  if (listGuardians(row.ward_slug).length <= 1) {
+    return { outcome, applied: false, refused: 'would_emancipate', row };
+  }
+  removeRelation(row.ward_slug, 'ward', row.target_uri);
+  db.prepare('UPDATE ap_lapses SET applied = 1 WHERE id = ?').run(id);
+  return { outcome, applied: true, row };
+}
+
+/** The offers-queue items for running lapses this account is a party to:
+ *  the ward itself, or a co-located guardian in the set. Same shape as the
+ *  daemon's, so the Shaer clients render them as-is. */
+export function lapseQueueItems(slug, me, now) {
+  const items = [];
+  for (const row of db.prepare('SELECT * FROM ap_lapses WHERE applied = 0 AND cancelled = 0').all()) {
+    settleLapse(row.id, now);   // reads are where lazy completion happens
+    if (lapseOutcome(row, now) !== 'open') continue;
+    const set = JSON.parse(row.set_json);
+    if (row.ward_slug !== slug && !set.includes(me)) continue;
+    const accepts = JSON.parse(row.accepts_json);
+    const rejects = JSON.parse(row.rejects_json);
+    items.push({
+      id: row.id,
+      type: 'Offer',
+      actor: row.opened_by,
+      object: { type: 'shaer:Lapse', 'shaer:ward': row.ward_uri, object: row.target_uri },
+      'shaer:set': set,
+      'shaer:accepts': accepts.length,
+      'shaer:threshold': lapseThreshold(set.length),
+      'shaer:myVote': accepts.includes(me) || rejects.includes(me),
+      'shaer:outcome': 'open',
+      'shaer:closesAt': row.opened_at + row.window_ms,
+    });
+  }
+  return items;
+}
+
+export function getLapse(id) { return db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id); }
+
+export default {
+  POLICY, LAPSE_WINDOW_MS, isAway, parseEndTime, effective, awayUntil, declareAway,
+  recordRequest, misses, observe, oneAnswer, availableSet, statusesFor, wireAvailability,
+  parseLapse, lapseThreshold, lapseOutcome, openLapse, lapseVote, settleLapse, lapseQueueItems, getLapse,
+};
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/guardianship/delivery.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -40,5 +40,5 @@
 // guardian on any instance receives it as a private mention (the ward
 // call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave }) {
+export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
   const { actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
           getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
@@ -70,7 +70,7 @@
     .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
   const id = crypto.randomUUID();
-  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, created_at)
-              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
-    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0);
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
+              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
+    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
   const row = getOutboxRow(id);
   const note = buildReplyNote(base, site, row);
Index: src/services/guardianship/gated.js
===================================================================
--- src/services/guardianship/gated.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/guardianship/gated.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -15,4 +15,5 @@
 import db from '../../config/database.js';
 import { listGuardians } from './relations.js';
+import * as availability from './availability.js';
 
 /** The window a gated-setting decision stays open. Reversible, so a day. */
@@ -67,6 +68,13 @@
   const column = featureColumn(feature);
   if (!column) return { state: 'expired', error: 'unknown_feature' };
-  const guardians = listGuardians(slug).map((g) => g.other_uri);
-  if (!guardians.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
+  const all = listGuardians(slug).map((g) => g.other_uri);
+  if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
+  // A vote is an answer, whatever it is a vote on (§3.6): the voter is
+  // restored first, so it always counts itself back into the set below.
+  availability.oneAnswer(guardianUri, Date.now());
+  // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
+  // against the full list above: any guardian may answer, and answering is
+  // exactly what brings it back in.
+  const guardians = availability.availableSet(slug, all, Date.now());
 
   // The window opens with the first answer, and a stale decision starts over:
@@ -100,5 +108,6 @@
   const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
     .all(slug, feature);
-  const guardians = listGuardians(slug).map((g) => g.other_uri);
+  // Progress over the available set (§3.5), like the tally itself.
+  const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
   return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
 }
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/guardianship/handshake.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -20,4 +20,5 @@
 import * as relations from './relations.js';
 import * as gated from './gated.js';
+import * as availability from './availability.js';
 
 let deps = null;
@@ -194,4 +195,9 @@
   if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
   const me = deps.selfId(site.slug);
+  // One answer restores everything (§3.6): any C2S activity from this actor
+  // is that answer, for every local ward it guards. Runs before anything is
+  // even looked at, so the target of a running lapse cancels it by doing
+  // anything at all — including trying to vote on it.
+  try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
 
   // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
@@ -206,4 +212,24 @@
   // ── Offer: the local site is the guardian-candidate. ───────────────────
   if (type === 'Offer') {
+    // §3.6.3 over C2S: a guardian here proposes releasing a dormant
+    // co-guardian. A ward we host opens locally; a remote ward gets the
+    // proposal delivered, because the ward's server is the one that tallies
+    // and enforces (the §5.6 line: a guardian next door must not have more
+    // say than one far away).
+    const lp = availability.parseLapse(activity.object);
+    if (lp) {
+      const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
+      const wardSlug = deps.localSlug(lp.ward);
+      if (wardSlug) {
+        const r = availability.openLapse({ id, wardSlug, wardUri: lp.ward, target: lp.target, openedBy: me, now: Date.now() });
+        if (r.error) return { status: r.error === 'not_in_available_set' ? 403 : 409, error: r.error };
+        deps.deliverTo(site, lp.target, { id, type: 'Offer', actor: me, to: [lp.target], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }).catch(() => { /* best-effort */ });
+        notify(wardSlug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
+        return { status: 202, id, url: id, 'shaer:set': r.set, 'shaer:threshold': r.threshold };
+      }
+      const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
+      const delivered = await fanout(site, [lp.ward], offer);
+      return { status: 202, id, url: id, delivered };
+    }
     const rel = parseRelationship(activity.object);
     if (!rel) return null;
@@ -231,4 +257,12 @@
   const offerId = idOf(activity.object);
   if (!offerId) return { status: 400, error: 'missing_offer' };
+  // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
+  // and gated follows use, which is exactly why the Shaer clients need no
+  // new verbs for it.
+  if (availability.getLapse(offerId)) {
+    const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
+    if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
+    return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
+  }
   let offer = offers.getOffer(site.slug, offerId);
   if (!offer) return { status: 404, error: 'no_such_offer' };
@@ -273,4 +307,24 @@
       const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
       notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
+      return true;
+    }
+    // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
+    // ward. The ward's server opens, tallies and (after the full window)
+    // executes, exactly as it does for the gated settings above.
+    const lp = availability.parseLapse(activity.object);
+    if (lp) {
+      if (lp.ward !== me) return false;   // not our ward
+      const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
+      const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
+      if (r.error) {
+        notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
+        return true;   // consumed: the refusal is the answer
+      }
+      // The target is notified like any dormancy marking (§3.6.2): in
+      // protocol (a copy of the Offer, so one answer can cancel it) AND the
+      // §6 handle, which for a committed guardian is its inbox — the same
+      // door this delivery knocks on.
+      deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
+      notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
       return true;
     }
@@ -304,4 +358,12 @@
     return true;
   }
+  // §3.6.3: a set member answering a running lapse. Irreversible, so even a
+  // full tally leaves it open until the window closes (§3.5); the completion
+  // happens lazily on reads (queues) once the window has run.
+  if (availability.getLapse(offerId)) {
+    const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
+    notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
+    return true;
+  }
   let offer = offers.getOffer(site.slug, offerId);
   if (!offer) return false;
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/guardianship/index.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -16,8 +16,10 @@
  */
 export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
-export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js';
+export { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js';
 export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship } from './handshake.js';
-export { offersCollection, followsCollection, wardsCollection } from './queues.js';
+export { offersCollection, followsCollection, wardsCollection, guardiansCollection } from './queues.js';
+export * as availability from './availability.js';
+export { wireAvailability } from './availability.js';
 export * as follows from './follows.js';
 export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
Index: src/services/guardianship/notes.js
===================================================================
--- src/services/guardianship/notes.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/guardianship/notes.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -65,3 +65,12 @@
 }
 
-export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed };
+/** shaer:away (3.6.1): a guardian declaring itself away to its ward, with an
+ *  end. Rides a direct note like the help request, so a ward on a plain
+ *  server reads a human message; endTime is plain AS2. */
+export function awayProps(post) {
+  return (post && post.visibility === 'direct' && post.away_until)
+    ? { 'shaer:away': true, endTime: new Date(post.away_until).toISOString() }
+    : {};
+}
+
+export default { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed };
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/guardianship/queues.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -11,4 +11,5 @@
 import * as offers from './offers.js';
 import * as relations from './relations.js';
+import * as availability from './availability.js';
 
 const collection = (id, items) => ({
@@ -16,7 +17,11 @@
 });
 
-/** Pending offers where the local site is a party, each with its accept tally. */
+/** Pending offers where the local site is a party, each with its accept
+ *  tally. The same collection carries the running lapses (§3.6.3) this
+ *  account is a party to, exactly as the daemon serves them, so the Shaer
+ *  clients render both without a second fetch. */
 export function offersCollection(id, slug, me) {
   const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
+  items.push(...availability.lapseQueueItems(slug, me, Date.now()));
   return collection(id, items);
 }
@@ -34,3 +39,10 @@
 }
 
-export default { offersCollection, followsCollection, wardsCollection };
+/** The ward's guardians with their availability (§3.6.1: never public,
+ *  owner-only): the real size of the safety net. Same shape as the daemon. */
+export function guardiansCollection(id, slug) {
+  const uris = relations.listGuardians(slug).map((r) => r.other_uri);
+  return collection(id, availability.statusesFor(slug, uris, Date.now()));
+}
+
+export default { offersCollection, followsCollection, wardsCollection, guardiansCollection };
Index: src/services/guardianship/relations.js
===================================================================
--- src/services/guardianship/relations.js	(revision 6c152a5a2b3de8369604ab7ceccccad68732ae88)
+++ src/services/guardianship/relations.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -74,4 +74,5 @@
       follows: `${id}/queues/follows`,
       wards: `${id}/queues/wards`,
+      guardians: `${id}/queues/guardians`,
     },
   };
