Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision f3a2556ad5919c65a0ce1e55e8f050154d1dfa8e)
+++ src/config/database.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -776,4 +776,19 @@
   )`);
   db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
+  // Een kind dat zelf om een poort vraagt (shaer-8ru). Een VRAAG, geen stem:
+  // pas als een guardian hem oppakt wordt het een voorstel dat langs de tally
+  // gaat. handled_at in plaats van verwijderen -- wat een kind gevraagd heeft
+  // hoort terug te vinden te zijn, ook als het antwoord nee was.
+  db.exec(`CREATE TABLE IF NOT EXISTS ap_gate_requests (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    slug TEXT NOT NULL,                 -- de guardian die hem ontving
+    ward_uri TEXT NOT NULL,
+    feature TEXT NOT NULL,
+    note_uri TEXT,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    handled_at TEXT,
+    UNIQUE (slug, ward_uri, feature, handled_at)
+  )`);
+  db.exec('CREATE INDEX IF NOT EXISTS idx_ap_gate_requests_slug ON ap_gate_requests(slug, handled_at)');
   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)
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision f3a2556ad5919c65a0ce1e55e8f050154d1dfa8e)
+++ src/routes/guardian.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -620,35 +620,9 @@
   const site = siteForUser(req);
   if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.body?.uri || '').trim();
-  const allow = req.body?.allow === true;
-  if (!uri) return res.status(400).json({ error: 'empty_uri' });
-  // Only a guardian of this ward, and only for a ward we host: a setting on a
-  // remote ward belongs to that ward's own server (federating it is Fase 4).
-  const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
-  if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
-  // §5.6: propose it to the WARD'S server, wherever that is. The ward's server
-  // tallies (a majority of its guardians, §3.5) and enforces. Co-location is
-  // just the case where that server happens to be this one, so it takes the
-  // same road: propose, then let the tally decide. Anything else would make a
-  // guardian on the ward's own instance more powerful than one elsewhere.
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const feature = req.body.feature;   // normalised by the route above
-  const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-  const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
-  // ONE path, whether the ward lives here or on the other side of the world
-  // (Robins regel, 29-7): propose over the wire and let the ward's server do
-  // what it does for everyone. deliverToActor loops a local recipient back
-  // into the same inbox handler, so co-location changes the transport and
-  // nothing else. The old shortcut recorded the vote here directly, which is
-  // how the remote path stayed broken for a month without anyone noticing.
-  // Our own record of what we sent (5.6): the ward's server answers this Offer
-  // once the decision settles, and that answer needs a row to land in. It is
-  // also the only way the proposer's screen can say more than a button caption.
-  Guardianship.gated.recordSent(offerId, site.slug, uri, feature, allow);
-  AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
-  const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
-  const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
-  res.json({ ok: true, allow, state: 'open', ...(progress || { federated: true }) });
+  // De hele afweging staat in AP.proposeGate, zodat de apps langs dezelfde weg
+  // kunnen voorstellen (shaer-8ru). Deze route is nog maar de PWA-deur ernaartoe.
+  const uit = AP.proposeGate(site, req.body?.uri, req.body?.feature, req.body?.allow === true);
+  const { status, ...rest } = uit;
+  return res.status(status === 200 ? 200 : status).json(rest);
 }
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f3a2556ad5919c65a0ce1e55e8f050154d1dfa8e)
+++ src/services/ActivityPubService.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -2632,4 +2632,22 @@
             Guardianship.availability.declareAway(slug, actorUri, until);
             console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
+          }
+        }
+        // Een kind dat zelf om een poort vraagt (shaer-8ru). Zelfde weg als de
+        // afwezigheidsmelding: een gewone directe note met een shaer:-markering,
+        // per genoemde ontvanger afgehandeld.
+        //
+        // ALLEEN VAN EEN EIGEN WARD. Een verzoek van een vreemde is geen vraag
+        // maar een onbekende die iets over jouw instellingen wil zeggen -- dat
+        // hoort in geen enkele lijst te belanden waar een guardian op afgaat.
+        {
+          const req = Guardianship.gatereq.parseRequest(o);
+          if (req) {
+            for (const slug of slugs) {
+              const mijn = (() => { try { return Guardianship.listWards(slug).some((w) => w.other_uri === actorUri); } catch { return false; } })();
+              if (!mijn) { console.warn('[AP] gate request from someone who is not our ward, ignored:', actorUri, '→', slug); continue; }
+              Guardianship.gatereq.record(slug, actorUri, req.feature, o.id);
+              console.log('[AP] gate request', req.feature, actorUri, '→', slug);
+            }
           }
         }
@@ -3237,4 +3255,15 @@
   try {
     switch (type) {
+      // Een gate-voorstel uit de app (5.6, shaer-8ru). De PWA deed dit al over
+      // een eigen route; hier komt het binnen als wat het in AP IS -- een Offer
+      // van een shaer:GatedSetting. Dezelfde functie erachter, want twee wegen
+      // naar hetzelfde besluit is precies wat we vandaag hebben rechtgezet.
+      case 'Offer': {
+        const gs = Guardianship.gated.parseGatedSetting(object);
+        if (!gs) return { status: 400, error: 'unsupported_offer' };
+        const uit = proposeGate(site, gs.ward, gs.feature, gs.value);
+        if (uit.status !== 200) return uit;
+        return { ...uit, status: 201, id: uit.offerId };
+      }
       case 'Create': {
         if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
@@ -3248,6 +3277,13 @@
           const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
           const isHelp = object['shaer:helpRequest'] === true || object.helpRequest === true;
+          // Een poortverzoek van het kind zelf (shaer-8ru) gaat langs de
+          // messages-poort. Dat lijkt een gat en is het niet: het verzoek draagt
+          // ALLEEN de naam van de feature, geen vrije tekst, dus er ontstaat geen
+          // kanaal om omheen die poort te praten. Zonder deze uitzondering kan
+          // een kind met berichten dicht nergens meer om vragen -- en dan is de
+          // hele weg dood op precies het moment dat hij nodig is.
+          const isGateReq = !!Guardianship.gatereq.parseRequest(object);
           const direct = c2sVisibility(object) === 'direct';
-          if (!isHelp) {
+          if (!isHelp && !isGateReq) {
             if (direct && !Guardianship.wardGateAllowed(site.gate_messages, isWard)) {
               return { status: 403, error: 'gated_messages' };
@@ -3307,5 +3343,6 @@
             // absence like it does for a ward anywhere else. One path.
           }
-          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 });
+          const gateReq = Guardianship.gatereq.parseRequest(object);
+          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, gateRequest: gateReq && gateReq.feature });
           if (!r || !r.id) return { status: 502, error: 'direct_failed' };
           return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
@@ -3538,4 +3575,43 @@
 // Send a reply FROM this site to a remote actor (in reply to their inbound reply).
 // `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
+/**
+ * Een gate-voorstel de deur uit (FEP-633c 5.6, shaer-8ru).
+ *
+ * STOND IN routes/guardian.js en kon daar alleen door de PWA aangeroepen worden.
+ * De apps moeten hetzelfde kunnen, en een tweede implementatie ernaast zou een
+ * tweede weg naar hetzelfde besluit zijn -- precies de fout die we vandaag bij
+ * de antwoordpoort hebben rechtgezet, toen de innamepoort alleen in C2S bleek te
+ * zitten en het webpad eromheen liep. Een pad dus.
+ *
+ * EEN WEG, waar de ward ook woont (Robins regel, 29-7): voorstellen over de
+ * lijn en de server van de ward laat tellen. Co-locatie verandert alleen het
+ * transport -- deliverToActor lust een lokale ontvanger terug door dezelfde
+ * inbox. De oude kortsluiting boekte de stem hier meteen, en zo bleef het
+ * remote-pad een maand stuk zonder dat iemand het merkte.
+ */
+export function proposeGate(site, wardUri, feature, allow) {
+  const uri = String(wardUri || '').trim();
+  if (!uri) return { status: 400, error: 'empty_uri' };
+  if (!Guardianship.gated.featureColumn(feature)) return { status: 400, error: 'unknown_feature' };
+  // Alleen een guardian van dit kind. Zonder deze regel zou iedereen met een
+  // token een instelling van een vreemd kind kunnen aanvragen.
+  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
+    return { status: 403, error: 'not_your_ward' };
+  }
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const me = actorId(base, site.slug);
+  const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
+  const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
+  // Ons eigen spoor van wat we stuurden: de server van de ward antwoordt op deze
+  // Offer zodra het besluit valt, en dat antwoord heeft een rij nodig om in te
+  // landen. Het is ook het enige waardoor het scherm van de voorsteller meer kan
+  // zeggen dan een knoptekst.
+  Guardianship.gated.recordSent(offerId, site.slug, uri, feature, allow);
+  deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
+  const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
+  const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
+  return { status: 200, ok: true, allow, state: 'open', offerId, ...(progress || { federated: true }) };
+}
+
 export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions, visibility }) {
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
@@ -6113,5 +6189,5 @@
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
-  getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
+  proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
   noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision f3a2556ad5919c65a0ce1e55e8f050154d1dfa8e)
+++ src/services/guardianship/delivery.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -41,5 +41,5 @@
 // guardian on any instance receives it as a private mention (the ward
 // call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
+export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark, gateRequest }) {
   const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
           getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
@@ -116,4 +116,7 @@
     note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
   }
+  // Een kind dat zelf om een poort vraagt (shaer-8ru). Alleen de naam van de
+  // feature reist mee -- geen vrije tekst, zie gatereq.js.
+  if (gateRequest) note['shaer:gateRequest'] = String(gateRequest);
   const create = {
     '@context': AP_CONTEXT,
Index: src/services/guardianship/gatereq.js
===================================================================
--- src/services/guardianship/gatereq.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
+++ src/services/guardianship/gatereq.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -0,0 +1,99 @@
+/**
+ * Een kind dat zelf om een poort vraagt (shaer-8ru, Barts opdracht 8-8).
+ *
+ * Tot nu toe liep alles over de guardians: zij zien de catalogus, zij stellen
+ * voor, zij tellen. Het kind liep tegen een dichte deur en had geen woorden.
+ * Dit is die woorden -- niet meer dan dat.
+ *
+ * EEN VRAAG IS GEEN STEM, en dat is de hele grens. De ward stelt niet voor en
+ * stemt niet mee; het verzoek landt bij zijn guardians als iets om over te
+ * beslissen, en pas als EEN GUARDIAN het oppakt wordt het een voorstel dat
+ * langs de gewone tally gaat. Zou een verzoek zelf een voorstel zijn, dan kon
+ * een kind zijn eigen poort openen door hard genoeg te vragen.
+ *
+ * NIET DE REDDINGSBOEI, en dat verschil moet scherp blijven. Een hulpvraag is
+ * een noodgeval en gaat door elke dichte deur heen. Dit is een wens. Ze door
+ * elkaar laten lopen zou de boei devalueren tot "het kind wil iets", en dan
+ * kijkt er op een dag niemand meer op als hij afgaat.
+ *
+ * GEEN VRIJE TEKST. Een verzoek draagt alleen de naam van de feature. Dat is
+ * niet gierig maar precies de reden dat hij langs de messages-poort MAG: een
+ * kind met berichten dicht kan nog steeds om iets vragen, zonder dat daarmee
+ * een kanaal ontstaat om omheen die poort te praten. Wil het kind uitleggen
+ * waarom, dan is dat een gesprek, en gesprekken hebben hun eigen poort.
+ */
+
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_gate_requests (slug, ward_uri, feature, note_uri)
+                       VALUES (?,?,?,?)`),
+      bySlug: db.prepare(`SELECT * FROM ap_gate_requests WHERE slug = ? AND handled_at IS NULL
+                          ORDER BY created_at DESC`),
+      handle: db.prepare(`UPDATE ap_gate_requests SET handled_at = CURRENT_TIMESTAMP
+                          WHERE slug = ? AND ward_uri = ? AND feature = ? AND handled_at IS NULL`),
+    };
+  }
+  return _s;
+}
+
+/** Leg vast dat dit kind om deze poort vroeg. Nooit dragend: een verzoek dat
+ *  niet opgeslagen kan worden mag geen inkomend bericht laten stranden. */
+export function record(slug, wardUri, feature, noteUri = null) {
+  if (!slug || !wardUri || !feature) return;
+  try { stmts().ins.run(slug, wardUri, feature, noteUri); } catch { /* nooit dragend */ }
+}
+
+/** De openstaande verzoeken van de kinderen van deze guardian. */
+export function listOpen(slug) {
+  try { return stmts().bySlug.all(slug); } catch { return []; }
+}
+
+/**
+ * Afgehandeld: er is een voorstel van gemaakt, of een guardian legde hem weg.
+ *
+ * Verdwijnt niet uit de tabel. Er wordt niets herschreven, er wordt toegevoegd
+ * -- zelfde regel als bij de hulpvraag, en om dezelfde reden: wat een kind
+ * gevraagd heeft hoort terug te vinden te zijn, ook als het antwoord nee was.
+ */
+export function markHandled(slug, wardUri, feature) {
+  try { stmts().handle.run(slug, wardUri, feature); } catch { /* nooit dragend */ }
+}
+
+/**
+ * Hoeveel verzoeken er per feature openstaan voor dit kind, voor de
+ * waiting-kolom van het paneel. Zo staat de vraag bij de poort waar hij over
+ * gaat, en niet in een aparte lijst die je apart moet openen.
+ */
+export function waitingFor(slug, wardUri) {
+  const uit = {};
+  for (const r of listOpen(slug)) {
+    if (r.ward_uri !== wardUri) continue;
+    uit[r.feature] = (uit[r.feature] || 0) + 1;
+  }
+  return uit;
+}
+
+/** Het verzoek als bericht. Bewust dezelfde vorm als de zwaai en de
+ *  hulpmarkering: een gewone directe note met een shaer:-markering, zodat hij
+ *  over de bestaande bezorging reist en niet over een eigen kanaal. */
+export function requestNote({ id, me, feature, to }) {
+  return {
+    id, type: 'Note', attributedTo: me, to,
+    'shaer:gateRequest': feature,
+    // Vaste tekst, geen invoer van het kind: zie de kop over vrije tekst.
+    content: '<p>Mag dit aan?</p>',
+  };
+}
+
+/** Leest een binnengekomen note als poortverzoek, of null als hij er geen is. */
+export function parseRequest(object) {
+  if (!object || typeof object !== 'object') return null;
+  const f = object['shaer:gateRequest'] || object.gateRequest;
+  return (typeof f === 'string' && f) ? { feature: f } : null;
+}
+
+export default { record, listOpen, markHandled, waitingFor, requestNote, parseRequest };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision f3a2556ad5919c65a0ce1e55e8f050154d1dfa8e)
+++ src/services/guardianship/index.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -35,2 +35,3 @@
 // 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
 export * as help from './help.js';
+export * as gatereq from './gatereq.js';
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision f3a2556ad5919c65a0ce1e55e8f050154d1dfa8e)
+++ src/services/guardianship/queues.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -15,4 +15,5 @@
 import * as follows from './follows.js';
 import * as gated from './gated.js';
+import * as gatereq from './gatereq.js';
 import * as handshake from './handshake.js';
 
@@ -169,5 +170,9 @@
       feature: p.feature, value: !!p.value, status: gated.sentStatus(p, Date.now()),
     })),
-    waiting: { 'shaer:follows': wachtend || undefined },
+    // Wat er op deze poort wacht: volgverzoeken bij de volgpoort, en de vraag
+    // van het kind zelf bij de poort waar hij over gaat (shaer-8ru). Zo staat
+    // hij waar je hem nodig hebt, en niet in een aparte lijst die je apart moet
+    // openen -- en die je dus vergeet.
+    waiting: { ...gatereq.waitingFor(mySlug, wardUri), 'shaer:follows': wachtend || undefined },
   });
 }
Index: test/gate-request.test.js
===================================================================
--- test/gate-request.test.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
+++ test/gate-request.test.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -0,0 +1,86 @@
+// Een kind dat zelf om een poort vraagt (shaer-8ru, Barts opdracht 8-8).
+//
+// Tot nu toe liep alles over de guardians: zij zien de catalogus, zij stellen
+// voor, zij tellen. Het kind liep tegen een dichte deur en had geen woorden.
+//
+// De twee dingen die hier stil kunnen breken zijn allebei erg:
+//   - een VRAAG die als STEM telt: dan opent een kind zijn eigen poort door
+//     hard genoeg te vragen;
+//   - een vraag die door de reddingsboei loopt: dan devalueert de boei tot
+//     "het kind wil iets", en kijkt er op een dag niemand meer op als hij afgaat.
+//
+// In-memory SQLite. Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://oma.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const Guardianship = await import('../src/services/guardianship/index.js');
+const gatereq = await import('../src/services/guardianship/gatereq.js');
+const rel = await import('../src/services/guardianship/relations.js');
+const queues = await import('../src/services/guardianship/queues.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'oma', 'Oma', 'u1');
+const KIND = 'https://elders.test/ap/users/kind';
+rel.commitWardForGuardian('oma', KIND, { handle: '@kind' });
+
+test('een verzoek van een eigen ward wordt bewaard', () => {
+  gatereq.record('oma', KIND, 'shaer:images', 'https://elders.test/ap/notes/1');
+  const open = gatereq.listOpen('oma');
+  assert.equal(open.length, 1);
+  assert.equal(open[0].feature, 'shaer:images');
+});
+
+test('een VRAAG is geen STEM', () => {
+  // De grens van dit hele bead. Een verzoek mag nergens als voorstel of als
+  // stem opduiken: anders opent een kind zijn eigen poort door te vragen.
+  assert.equal(Guardianship.gated.listSent('oma', KIND).length, 0);
+  const rij = queues.wardGates('oma', KIND).find((g) => g.feature === 'shaer:images');
+  assert.equal(rij.proposal, undefined, 'geen voorstel');
+  assert.notEqual(rij.value, true, 'en zeker geen open poort');
+});
+
+test('de vraag staat BIJ de poort waar hij over gaat', () => {
+  // Niet in een aparte lijst die je apart moet openen, en dus vergeet.
+  const rij = queues.wardGates('oma', KIND).find((g) => g.feature === 'shaer:images');
+  assert.equal(rij.waiting, 1);
+});
+
+test('afgehandeld verdwijnt uit het zicht maar niet uit de geschiedenis', () => {
+  // Er wordt niets herschreven, er wordt toegevoegd -- zelfde regel als bij de
+  // hulpvraag: wat een kind gevraagd heeft hoort terug te vinden te zijn, ook
+  // als het antwoord nee was.
+  gatereq.markHandled('oma', KIND, 'shaer:images');
+  assert.equal(gatereq.listOpen('oma').length, 0);
+  const alles = db.prepare('SELECT * FROM ap_gate_requests WHERE ward_uri = ?').all(KIND);
+  assert.equal(alles.length, 1);
+  assert.ok(alles[0].handled_at, 'met een stempel erop');
+});
+
+test('een verzoek zonder feature is geen verzoek', () => {
+  assert.equal(gatereq.parseRequest({ content: 'hoi' }), null);
+  assert.equal(gatereq.parseRequest({ 'shaer:gateRequest': '' }), null);
+  assert.deepEqual(gatereq.parseRequest({ 'shaer:gateRequest': 'shaer:music' }), { feature: 'shaer:music' });
+});
+
+test('het verzoek draagt GEEN vrije tekst van het kind', () => {
+  // Dat is niet gierig maar precies de reden dat hij langs de messages-poort
+  // mag: anders is dit een kanaal om omheen die poort te praten.
+  const note = gatereq.requestNote({ id: 'x', me: 'https://k/u/kind', feature: 'shaer:music', to: ['https://oma.test/ap/users/oma'] });
+  assert.equal(note['shaer:gateRequest'], 'shaer:music');
+  assert.equal(note.content, '<p>Mag dit aan?</p>');
+});
+
+test('een verzoek is NIET de reddingsboei', () => {
+  // Ze door elkaar laten lopen devalueert de boei. Een verzoek draagt geen
+  // helpRequest-vlag, en een hulpvraag geen feature.
+  const note = gatereq.requestNote({ id: 'x', me: 'https://k/u/kind', feature: 'shaer:music', to: [] });
+  assert.equal(note['shaer:helpRequest'], undefined);
+  assert.equal(gatereq.parseRequest({ 'shaer:helpRequest': true }), null);
+});
Index: test/gated-propose-c2s.test.js
===================================================================
--- test/gated-propose-c2s.test.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
+++ test/gated-propose-c2s.test.js	(revision 2bfe26cd31fe697a3e1e003e8b2e00cc1da83959)
@@ -0,0 +1,77 @@
+// Een gate voorstellen vanuit de app (shaer-8ru, FEP-633c 5.6).
+//
+// De PWA stelde al voor over een eigen route; de apps konden alleen KIJKEN. Wat
+// hier bewaakt wordt is niet dat het werkt, maar dat het langs DEZELFDE weg gaat:
+// een tweede implementatie ernaast geeft vroeg of laat een ander antwoord, en bij
+// de antwoordpoort was dat vandaag precies het gat (de innamepoort zat alleen in
+// C2S, het webpad liep eromheen).
+//
+// In-memory SQLite. Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://oma.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+const Guardianship = await import('../src/services/guardianship/index.js');
+const rel = await import('../src/services/guardianship/relations.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'oma', 'Oma', 'u1');
+const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+const user = { id: 'u1', username: 'u1' };
+const KIND = 'https://elders.test/ap/users/kind';
+rel.commitWardForGuardian('oma', KIND, { handle: '@kind' });
+
+const offer = (ward, feature, value) => ({
+  type: 'Offer',
+  object: { type: 'shaer:GatedSetting', 'shaer:ward': ward, 'shaer:feature': feature, 'shaer:value': value },
+});
+
+test('een guardian stelt een gate voor vanuit de app', async () => {
+  const uit = await AP.ingestOutboxActivity(site(), user, offer(KIND, 'shaer:images', true));
+  assert.equal(uit.status, 201);
+  assert.equal(uit.state, 'open');
+  // Het spoor van wat we stuurden: zonder rij kan het antwoord van de
+  // ward-server nergens landen, en zegt het scherm nooit meer dan een knoptekst.
+  const sent = Guardianship.gated.listSent('oma', KIND);
+  assert.ok(sent.some((p) => p.feature === 'shaer:images'), 'het voorstel is vastgelegd');
+});
+
+test('niet voor een kind dat niet van jou is', async () => {
+  // Zonder deze regel kan iedereen met een token een instelling van een vreemd
+  // kind aanvragen.
+  const uit = await AP.ingestOutboxActivity(site(), user, offer('https://elders.test/ap/users/vreemde', 'shaer:images', true));
+  assert.equal(uit.status, 403);
+  assert.equal(uit.error, 'not_your_ward');
+});
+
+test('een onbekende feature wordt GEWEIGERD, niet herschreven', () => {
+  // De oude route herschreef een onbekende naam stilletjes naar externalEmbeds.
+  // Een voorstel voor de ene poort dat op de andere landt is het soort fout dat
+  // een guardian nooit mag overkomen.
+  const uit = AP.proposeGate(site(), KIND, 'shaer:ietsNieuws', true);
+  assert.equal(uit.status, 400);
+  assert.equal(uit.error, 'unknown_feature');
+});
+
+test('een Offer dat GEEN gate-voorstel is wordt niet stilletjes geslikt', async () => {
+  const uit = await AP.ingestOutboxActivity(site(), user, { type: 'Offer', object: { type: 'Note', content: 'hoi' } });
+  assert.equal(uit.status, 400);
+  assert.equal(uit.error, 'unsupported_offer');
+});
+
+test('de app en de PWA lopen door DEZELFDE functie', async () => {
+  // Niet dat ze hetzelfde doen, maar dat er maar een plek is die het doet: de
+  // C2S-weg boekt zijn voorstel in hetzelfde register waar de PWA uit leest.
+  const voor = Guardianship.gated.listSent('oma', KIND).length;
+  await AP.ingestOutboxActivity(site(), user, offer(KIND, 'shaer:music', true));
+  const na = Guardianship.gated.listSent('oma', KIND);
+  assert.equal(na.length, voor + 1);
+  assert.ok(na.some((p) => p.feature === 'shaer:music'));
+});
