Index: src/services/guardianship/gated.js
===================================================================
--- src/services/guardianship/gated.js	(revision 65abc8569b0cf766ad2c809114e7f385c1aff3b8)
+++ src/services/guardianship/gated.js	(revision 65abc8569b0cf766ad2c809114e7f385c1aff3b8)
@@ -0,0 +1,153 @@
+/**
+ * Guardianship (FEP-633c §5.6): gated settings the guardians decide together.
+ *
+ * The point of this file is that it works when the guardians are NOT on the
+ * ward's server, which is the ordinary case: a child on the family instance, a
+ * grandparent on theirs. A guardian proposes with an `Offer` of a
+ * `shaer:GatedSetting` addressed to the ward's server; the other guardians
+ * answer; the ward's server tallies and enforces, because it is the one that
+ * serves the feed.
+ *
+ * The tally is a §3.5 decision: a snapshotted set, a threshold (strict
+ * majority), a window. A setting is reversible (a permission granted can be
+ * withdrawn), so it settles as a race to the threshold and fails closed.
+ */
+import db from '../../config/database.js';
+import { listGuardians } from './relations.js';
+
+/** The window a gated-setting decision stays open. Reversible, so a day. */
+export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
+
+/** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
+export function thresholdFor(setSize) {
+  return Math.floor(setSize / 2) + 1;
+}
+
+/**
+ * Tally one decision. Pure, so the rule can be tested without a database.
+ *
+ * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
+ * @param {string[]} guardianSet  the guardians at the moment the decision opened
+ * @param {number} ageMs          how long the decision has been open
+ * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
+ */
+export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
+  const set = new Set((guardianSet || []).filter(Boolean));
+  if (!set.size) return { state: 'expired' };            // nobody may decide
+  const need = thresholdFor(set.size);
+  // Only answers from the snapshotted set count, one per guardian.
+  const seen = new Map();
+  for (const v of (votes || [])) {
+    if (!set.has(v.guardian_uri)) continue;
+    seen.set(v.guardian_uri, v.value === true || v.value === 1);
+  }
+  const yes = [...seen.values()].filter(Boolean).length;
+  const no = seen.size - yes;
+  // Race to the threshold, in both directions: settle the moment it is reached,
+  // and give up the moment it can no longer be reached.
+  if (yes >= need) return { state: 'settled', value: true };
+  if (no >= need) return { state: 'settled', value: false };
+  const undecided = set.size - seen.size;
+  if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
+  if (ageMs >= windowMs) return { state: 'expired' };    // fails closed
+  return { state: 'open' };
+}
+
+/** The column a feature maps onto. Unknown features are refused, not guessed. */
+const FEATURES = { 'shaer:externalEmbeds': 'external_embeds' };
+export function featureColumn(feature) {
+  return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
+}
+
+/**
+ * Record one guardian's answer and settle if the threshold is now reached.
+ * Returns the tally state so a caller can report it.
+ */
+export function recordGatedVote(slug, feature, guardianUri, value) {
+  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' };
+
+  // The window opens with the first answer, and a stale decision starts over:
+  // a proposal from last month should not silently count toward today's.
+  const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
+    .get(slug, feature);
+  let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
+  if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
+    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
+    openedAt = Date.now();
+  }
+  db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
+              VALUES (?,?,?,?,?)
+              ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
+    .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
+
+  const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
+    .all(slug, feature);
+  const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
+  if (result.state === 'settled') {
+    db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
+    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
+  } else if (result.state === 'expired') {
+    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
+  }
+  return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
+}
+
+/** The open decision for a feature, for showing progress ("1 of 2"). */
+export function gatedProgress(slug, feature) {
+  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);
+  return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
+}
+
+// ── The federated shape (§5.6) ────────────────────────────────────
+// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
+// here so both the inbox and the outbox read it the same way.
+
+/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
+export function parseGatedSetting(object) {
+  if (!object || typeof object !== 'object') return null;
+  const type = Array.isArray(object.type) ? object.type[0] : object.type;
+  if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
+  const ward = object['shaer:ward'] || object.ward;
+  const feature = object['shaer:feature'] || object.feature;
+  const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
+  if (typeof ward !== 'string' || typeof feature !== 'string') return null;
+  return { ward, feature, value: value === true || value === 1 || value === 'true' };
+}
+
+/** Build the Offer a guardian sends to the ward's server. */
+export function buildGatedOffer(offerId, actor, ward, feature, value) {
+  return {
+    id: offerId,
+    type: 'Offer',
+    actor,
+    to: [ward],
+    object: {
+      type: 'shaer:GatedSetting',
+      'shaer:ward': ward,
+      'shaer:feature': feature,
+      'shaer:value': !!value,
+    },
+  };
+}
+
+export function rememberGatedOffer(offerId, slug, feature, value) {
+  try {
+    db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value) VALUES (?,?,?,?)')
+      .run(offerId, slug, feature, value ? 1 : 0);
+  } catch { /* non-fatal */ }
+}
+
+export function recallGatedOffer(offerId) {
+  try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
+  catch { return null; }
+}
+
+export default {
+  tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
+  parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
+};
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
+++ src/services/guardianship/handshake.js	(revision 65abc8569b0cf766ad2c809114e7f385c1aff3b8)
@@ -19,4 +19,5 @@
 import * as offers from './offers.js';
 import * as relations from './relations.js';
+import * as gated from './gated.js';
 
 let deps = null;
@@ -155,5 +156,16 @@
   const actor = idOf(activity.actor);
 
+  // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
+  // tallies and enforces, so the decision lands here, not on the proposer.
   if (type === 'Offer') {
+    const gs = gated.parseGatedSetting(activity.object);
+    if (gs) {
+      if (gs.ward !== me) return false;                       // not our ward
+      gated.rememberGatedOffer(idOf(activity), site.slug, gs.feature, gs.value);
+      // The proposer's Offer carries its own agreement (§3.1's one-step clause).
+      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;
+    }
     const rel = parseRelationship(activity.object);
     if (!rel) return false;
@@ -175,4 +187,14 @@
   // Accept / Reject of an offer we (also) track.
   const offerId = idOf(activity.object);
+  // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
+  // references the offer, so the value comes from the proposal we stored. A
+  // Reject is a vote for the opposite, not a shrug: it is still an answer.
+  const gsOffer = gated.recallGatedOffer(offerId);
+  if (gsOffer && gsOffer.slug === site.slug) {
+    const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
+    const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
+    notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
+    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 fc404105536bc9fcadc1fdb7dceb0449fee77e98)
+++ src/services/guardianship/index.js	(revision 65abc8569b0cf766ad2c809114e7f385c1aff3b8)
@@ -26,2 +26,5 @@
   actorProps as guardianshipActorProps,
 } from './relations.js';
+
+// §5.6 gated settings (decided by the guardians, enforced by the ward's server)
+export * as gated from './gated.js';
