Index: test/gates-family.test.js
===================================================================
--- test/gates-family.test.js	(revision 3b43e4c42e059a39a2ea3ecd827e68d6c1d8a4e6)
+++ test/gates-family.test.js	(revision 3b43e4c42e059a39a2ea3ecd827e68d6c1d8a4e6)
@@ -0,0 +1,105 @@
+// De gate-familie functioneel (shaer-ahy.1, Barts opdracht 8-8): niet alleen
+// een toets in het paneel, maar een poort die echt dichtkan -- bij de
+// AFLEVERING (wat dicht is wordt nooit geserialiseerd) of bij de INNAME (wat
+// de ward niet mag versturen wordt aan de outbox geweigerd).
+//
+// De twee dingen die hier stil kunnen breken: een poort die dicht lijkt maar
+// niets tegenhoudt (een guardian waant het kind beschermd), en een poort die
+// het hulpkanaal mee afsluit (de boei MOET door elke dichte deur heen).
+//
+// 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://klonkt.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');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
+const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+const user = { id: 'u1', username: 'u1' };
+
+test('elke beschikbare gate in de catalogus heeft een kolom', () => {
+  // De bewaker van de bedrading: een catalogusrij zonder kolom is een toets
+  // die nergens op aangesloten is, en dat merkt pas iemand die erop drukt.
+  for (const g of Guardianship.gated.GATE_CATALOGUE.filter((x) => x.available !== false)) {
+    if (g.fixed) continue;   // shaer:follows is een stroom, geen stand
+    assert.ok(Guardianship.gated.featureColumn(g.feature), `${g.feature} mist een kolom`);
+  }
+});
+
+test('de generieke regel: expliciet wint, de automatiek sluit voor een ward', () => {
+  assert.equal(Guardianship.wardGateAllowed(null, false), true);
+  assert.equal(Guardianship.wardGateAllowed(null, true), false);
+  assert.equal(Guardianship.wardGateAllowed(1, true), true);    // guardians openden hem
+  assert.equal(Guardianship.wardGateAllowed(0, false), false);  // expliciete 0 wint ook zonder wardstatus
+});
+
+test('gateAttachments: beeld en muziek dicht laten de rest staan, leeg wordt undefined', () => {
+  const atts = [
+    { type: 'Document', mediaType: 'image/webp', url: 'https://x/p.webp' },
+    { type: 'Audio', mediaType: 'audio/mpeg', url: 'https://x/a.mp3' },
+    { type: 'Document', mediaType: 'video/mp4', url: 'https://x/v.mp4' },
+  ];
+  const zonderBeeld = AP.gateAttachments(atts, { images: false });
+  assert.deepEqual(zonderBeeld.map((a) => a.mediaType), ['audio/mpeg', 'video/mp4']);
+  const zonderMuziek = AP.gateAttachments(atts, { audio: false });
+  assert.deepEqual(zonderMuziek.map((a) => a.mediaType), ['image/webp', 'video/mp4']);
+  // Alles weg is undefined, zoals de serialisatie een leeg veld overal weglaat.
+  assert.equal(AP.gateAttachments([atts[0]], { images: false }), undefined);
+  // En de poort open verandert niets.
+  assert.equal(AP.gateAttachments(atts, {}).length, 3);
+});
+
+test('stripEmojiTags haalt alleen de Emoji-tags weg', () => {
+  const tags = [
+    { type: 'Emoji', name: ':hartje:', icon: { url: 'https://x/h.png' } },
+    { type: 'Mention', href: 'https://x/u', name: '@u' },
+    { type: 'Hashtag', name: '#muziek' },
+  ];
+  const uit = AP.stripEmojiTags(tags);
+  assert.deepEqual(uit.map((t) => t.type), ['Mention', 'Hashtag']);
+  assert.equal(AP.stripEmojiTags([tags[0]]), undefined);
+});
+
+test('compose dicht weigert een eigen post aan de outbox, maar geen antwoord', async () => {
+  db.prepare("UPDATE sites SET gate_compose = 0 WHERE slug = 'kind'").run();
+  const post = { type: 'Create', object: { type: 'Note', content: '<p>hoi</p>', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
+  const uit = await AP.ingestOutboxActivity(site(), user, post);
+  assert.equal(uit.status, 403);
+  assert.equal(uit.error, 'gated_compose');
+  // Een antwoord is meedoen aan een gesprek, geen eigen podium: dat valt
+  // onder messages/het gesprek, niet onder compose.
+  const reply = { type: 'Create', object: { type: 'Note', content: '<p>ja!</p>', inReplyTo: 'https://elders/x', to: ['https://www.w3.org/ns/activitystreams#Public'] } };
+  const uit2 = await AP.ingestOutboxActivity(site(), user, reply);
+  assert.notEqual(uit2.error, 'gated_compose');
+  db.prepare("UPDATE sites SET gate_compose = NULL WHERE slug = 'kind'").run();
+});
+
+test('messages dicht weigert een direct bericht, maar NOOIT de reddingsboei', async () => {
+  db.prepare("UPDATE sites SET gate_messages = 0 WHERE slug = 'kind'").run();
+  const dm = { type: 'Create', object: { type: 'Note', content: '<p>psst</p>', to: ['https://elders/ap/users/vreemde'] } };
+  const uit = await AP.ingestOutboxActivity(site(), user, dm);
+  assert.equal(uit.status, 403);
+  assert.equal(uit.error, 'gated_messages');
+  // De boei gaat door elke dichte deur heen: een poort die het hulpkanaal
+  // afsnijdt beschermt niemand.
+  const hulp = { type: 'Create', object: { type: 'Note', content: '<p>🛟</p>', 'shaer:helpRequest': true, to: ['https://elders/ap/users/oma'] } };
+  const uit2 = await AP.ingestOutboxActivity(site(), user, hulp);
+  assert.notEqual(uit2.error, 'gated_messages');
+  db.prepare("UPDATE sites SET gate_messages = NULL WHERE slug = 'kind'").run();
+});
+
+test('accountMove dicht weigert de verhuizing voordat er iets vertrekt', async () => {
+  db.prepare("UPDATE sites SET gate_account_move = 0 WHERE slug = 'kind'").run();
+  const uit = await AP.moveAccount(site(), '@iemand@elders.example');
+  assert.equal(uit.error, 'guarded_account');
+  db.prepare("UPDATE sites SET gate_account_move = NULL WHERE slug = 'kind'").run();
+});
Index: test/thread.test.js
===================================================================
--- test/thread.test.js	(revision 2bd31d67a4567197583d1b78c6097c9c4b4558bf)
+++ test/thread.test.js	(revision 3b43e4c42e059a39a2ea3ecd827e68d6c1d8a4e6)
@@ -65,5 +65,5 @@
 
 test('een gewone lezer krijgt alles behalve de geblokkeerde, oudste eerst, geschoond', async () => {
-  const uit = await AP.getThread('kind', BRON, { isWard: false });
+  const uit = await AP.getThread('kind', BRON);
   assert.equal(uit.found, true);
   assert.equal(uit.notes.length, 2);
@@ -74,6 +74,7 @@
   assert.ok(!uit.notes[1].content.includes('<script'), 'script weggeschoond');
   assert.ok(uit.notes[1].content.includes('Hoi!'));
-  // Een blokkade is onzichtbaar: niet in de lijst en NIET in de telling.
-  assert.equal(uit.hidden, 0);
+  // Een blokkade is onzichtbaar: niet in de lijst en NIET in een telling --
+  // getThread telt sindsdien niets meer, dat doet de kringfilter in de route.
+  assert.equal(uit.hidden, undefined);
   assert.ok(uit.notes.every((n) => n.attributedTo !== GEBLOKT));
   // FEP-9098: alleen de echte emoji komt door -- niet de hashtag, niet de
@@ -86,19 +87,20 @@
 });
 
-test('een ward ziet alleen de kring van de guardians, de rest wordt geteld', async () => {
-  // Eigen cache-sleutel per (slug, uri) zou hier de vorige uitkomst hergeven;
-  // een andere slug dwingt een verse opbouw af zonder aan de cache te morrelen.
+test('de kringfilter houdt goedgekeurd volk en telt de rest (de dichte stand van de gate)', async () => {
+  // Sinds de gate (shaer-9y2) zit de kring in de ROUTE, per verzoek en buiten
+  // de threadcache om -- een poort die net dichtging mag niet twee minuten
+  // open nawerken. Hier de functie zelf.
   db.prepare("INSERT INTO sites (id, slug, title, owner_id) VALUES ('s2', 'pupil', 'Pupil', 'u1')").run();
   db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('pupil', ?, 'accepted')").run(TANTE);
-  db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('pupil', 'actor', ?)").run(GEBLOKT);
-  const uit = await AP.getThread('pupil', BRON, { isWard: true });
-  assert.equal(uit.notes.length, 1);
-  assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
+  const uit = await AP.getThread('kind', BRON);
+  const kring = AP.filterThreadToCircle('pupil', uit.notes);
+  assert.equal(kring.notes.length, 1);
+  assert.equal(kring.notes[0]['shaer:author'].name, 'tante');
   // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten.
-  assert.equal(uit.hidden, 1);
+  assert.equal(kring.hidden, 1);
 });
 
 test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => {
-  const uit = await AP.getThread('kind', 'https://203.0.113.99/weg', { isWard: false });
+  const uit = await AP.getThread('kind', 'https://203.0.113.99/weg');
   assert.equal(uit.found, false);
   assert.equal(uit.notes.length, 0);
@@ -126,5 +128,5 @@
     return vorige(url);
   };
-  const uit = await AP.getThread('kind', MPOST, { isWard: false });
+  const uit = await AP.getThread('kind', MPOST);
   globalThis.fetch = vorige;
   assert.equal(uit.notes.length, 1, 'het antwoord op de next-pagina is gevonden');
@@ -136,5 +138,5 @@
   const vorige = globalThis.fetch;
   globalThis.fetch = async (...a) => { calls += 1; return vorige(...a); };
-  const uit = await AP.getThread('kind', BRON, { isWard: false });
+  const uit = await AP.getThread('kind', BRON);
   assert.equal(uit.notes.length, 2);
   assert.equal(calls, 0, 'alles uit de cache, nul fetches');
