Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision ea211b9cdb42e4c59da937c4c2ab5162251ce374)
+++ src/services/ActivityPubService.js	(revision 29eb7459b738dadb3363af52947ca6358844a4e9)
@@ -110,7 +110,37 @@
   return true; // not an IP literal we recognise → refuse
 }
+/**
+ * Uitzonderingen op de SSRF-poort, voor een testkudde op de eigen machine
+ * (shaer-6wt: honderd wards met een guardian, Barts opdracht 8-8).
+ *
+ * WAAROM DIT MAG BESTAAN. De bescherming hierboven is er omdat een actor-URI van
+ * een VREEMDE komt: een aanvaller die "http://169.254.169.254/" doorgeeft laat
+ * ons zijn werk doen. Deze lijst gaat niet over vreemden -- hij staat in de
+ * omgeving van deze server, wordt door de beheerder gezet, en is leeg tenzij
+ * iemand hem expliciet vult.
+ *
+ * WAAROM HIJ ZO SMAL IS. Geen vlag die "loopback is oke" zegt, maar een lijst
+ * van precieze host:poort-paren. `[::1]:3060` opent niet 127.0.0.1, niet poort
+ * 3061, en niets in het interne netwerk. Een brede vlag zou de bescherming in
+ * een dev-omgeving uitzetten, en dev-omgevingen worden productie.
+ *
+ *   AP_ALLOW_HOSTS="[::1]:3060,[::1]:3061"
+ */
+const AP_ALLOW_HOSTS = new Set(
+  String(process.env.AP_ALLOW_HOSTS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean),
+);
+function isAllowedTestHost(u) {
+  if (!AP_ALLOW_HOSTS.size) return false;
+  return AP_ALLOW_HOSTS.has(u.host.toLowerCase());
+}
 async function assertPublicHost(hostname) {
-  if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
-  const addrs = await dns.promises.lookup(hostname, { all: true });
+  // URL.hostname geeft een IPv6-literal MET blokhaken ("[::1]"), en net.isIP
+  // herkent die vorm niet. Zonder strippen viel elk IPv6-adres door naar de
+  // DNS-tak, waar het strandde op ENOTFOUND: geweigerd, maar per ongeluk en met
+  // de verkeerde reden. isBlockedIp strippde ze al -- die verwachtte dus input
+  // die hier nooit aankwam.
+  const naakt = String(hostname || '').replace(/^\[|\]$/g, '');
+  if (net.isIP(naakt)) { if (isBlockedIp(naakt)) throw new Error('ssrf-blocked-ip'); return; }
+  const addrs = await dns.promises.lookup(naakt, { all: true });
   if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
 }
@@ -128,5 +158,7 @@
     const u = new URL(target); // throws on malformed → caller's catch
     if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
-    await assertPublicHost(u.hostname);
+    // Alleen op de precieze host:poort uit AP_ALLOW_HOSTS, en per hop opnieuw:
+    // een omleiding naar een ANDER intern adres blijft geweigerd.
+    if (!isAllowedTestHost(u)) await assertPublicHost(u.hostname);
     const r = await fetch(target, {
       ...opts,
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision ea211b9cdb42e4c59da937c4c2ab5162251ce374)
+++ src/services/guardianship/handshake.js	(revision 29eb7459b738dadb3363af52947ca6358844a4e9)
@@ -44,4 +44,34 @@
   if (type !== 'Undo') return null;
   return parseRelationship(activity && activity.object);
+}
+
+/**
+ * De overgebleven guardians opnieuw vertellen of ZIJ nu de doorslag geven
+ * (shaer-8vt, Barts correctie 8-8).
+ *
+ * "Doorslaggevend" is geen eigenschap van een moment maar van een STAND: zodra
+ * er nog een stem nodig is, is iedereen die nog moet antwoorden het. Eenmalig
+ * berekenen bij het doorsturen bevriest een antwoord dat verandert.
+ *
+ * Nooit dragend: lukt de update niet, dan blijft de oude waarde staan. Die is
+ * dan te voorzichtig of te stil -- en juist daarom staat de FAALSTAND aan de
+ * kant van waarschuwen (isDecisive leest onbekend als "ja, jij beslist").
+ */
+function herzieDoorslag(site, offerId, gsOffer, laatsteStem) {
+  try {
+    const p = gated.gatedProgress(site.slug, gsOffer.feature);
+    if (!gated.isDecisive(p.votes, p.need)) return;   // nog niets veranderd
+    const me = deps.selfId(site.slug);
+    const gestemd = new Set([gsOffer.proposer, laatsteStem].filter(Boolean));
+    for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
+      if (gestemd.has(g)) continue;
+      deps.deliverTo(site, g, {
+        id: offerId, type: 'Offer', actor: me, to: [g],
+        object: { type: 'shaer:GatedSetting', 'shaer:ward': me, 'shaer:feature': gsOffer.feature, 'shaer:value': !!gsOffer.value },
+        'shaer:proposer': gsOffer.proposer || undefined,
+        'shaer:decisive': true,
+      }).catch(() => { /* de bezorgwachtrij probeert opnieuw */ });
+    }
+  } catch { /* nooit dragend */ }
 }
 
@@ -537,4 +567,14 @@
     const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
     if (r.state === 'settled') answerGatedProposer(site, offerId, r);
+    // DOORSLAGGEVEND SCHUIFT MEE (Barts correctie, 8-8). Ik berekende dit een
+    // keer bij het doorsturen en bevroor het. Bij vijf guardians staat er dan
+    // "je beslist niets" -- en zodra er een ja bij komt IS elk van de anderen de
+    // doorslag. Dat is precies de stille kant: het scherm zwijgt op het moment
+    // dat het moet spreken.
+    //
+    // Dus na elke stem die het open laat: de overgeblevenen opnieuw vertellen
+    // waar ze staan. Alleen wie NOG NIET geantwoord heeft, en alleen als het
+    // antwoord verandert -- anders is dit een bericht per stem per guardian.
+    else herzieDoorslag(site, offerId, gsOffer, actor);
     notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
     return true;
Index: test/decisive-vote.test.js
===================================================================
--- test/decisive-vote.test.js	(revision ea211b9cdb42e4c59da937c4c2ab5162251ce374)
+++ test/decisive-vote.test.js	(revision 29eb7459b738dadb3363af52947ca6358844a4e9)
@@ -47,2 +47,15 @@
   assert.equal(gated.isDecisive(null, null), true);
 });
+
+test('doorslaggevend is een STAND, geen moment', () => {
+  // Barts correctie (8-8): het geldt voor ALLE opvolgende beslissers. Bij vijf
+  // guardians is bij het doorsturen niemand het (drempel 3, een stem), maar na
+  // de tweede ja is iedereen die nog moet antwoorden het wel.
+  const drempel = gated.thresholdFor(5);          // 3
+  assert.equal(gated.isDecisive(1, drempel), false, 'bij het doorsturen nog niet');
+  assert.equal(gated.isDecisive(2, drempel), true, 'na een ja erbij wel');
+});
+
+test('en bij drie guardians is het meteen zo', () => {
+  assert.equal(gated.isDecisive(1, gated.thresholdFor(3)), true);
+});
Index: test/ssrf-allowlist.test.js
===================================================================
--- test/ssrf-allowlist.test.js	(revision 29eb7459b738dadb3363af52947ca6358844a4e9)
+++ test/ssrf-allowlist.test.js	(revision 29eb7459b738dadb3363af52947ca6358844a4e9)
@@ -0,0 +1,61 @@
+// De SSRF-poort mag gericht open voor een testkudde op de eigen machine
+// (shaer-6wt, Barts vraag 8-8: "waarom via https? gewoon localhost ::1").
+//
+// Wat hier bewaakt wordt is de SMALHEID. De bescherming bestaat omdat een
+// actor-URI van een vreemde komt; een brede "loopback mag"-vlag zou hem in een
+// dev-omgeving uitzetten, en dev-omgevingen worden productie. Een lijst van
+// precieze host:poort-paren opent precies wat erin staat en niets ernaast.
+//
+// Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.AP_ALLOW_HOSTS = '[::1]:3060';
+const AP = await import('../src/services/ActivityPubService.js');
+
+const faalt = async (url) => {
+  try { await AP.safeFetch(url); return null; }
+  catch (e) { return e.message; }
+};
+
+test('wat er WEL op staat komt langs de poort', async () => {
+  // De slaagtest, en zonder hem is de rest waardeloos: alleen weigeringen
+  // toetsen laat de suite groen terwijl er niets doorheen komt. Er draait hier
+  // niets op 3060, dus we verwachten een VERBINDINGSfout -- het bewijs is dat
+  // het geen ssrf-fout is.
+  const fout = await faalt('http://[::1]:3060/u/w001');
+  assert.ok(fout, 'er is wel degelijk een fout, want er luistert niets');
+  assert.ok(!String(fout).startsWith('ssrf-'), `door de poort, maar kreeg: ${fout}`);
+});
+
+test('een adres dat NIET op de lijst staat blijft geweigerd', async () => {
+  // Zelfde machine, andere poort. Zou dit doorlaten, dan is de lijst een vlag.
+  assert.equal(await faalt('http://[::1]:3061/u/w001'), 'ssrf-blocked-ip');
+});
+
+test('en 127.0.0.1 evenmin, ook al is het dezelfde machine', async () => {
+  // De lijst opent een HOST:POORT, geen begrip van "lokaal".
+  assert.equal(await faalt('http://127.0.0.1:3060/u/w001'), 'ssrf-blocked-ip');
+});
+
+test('het metadata-adres blijft dicht', async () => {
+  // Waar de bescherming voor bestaat.
+  assert.equal(await faalt('http://169.254.169.254/latest/meta-data/'), 'ssrf-blocked-ip');
+});
+
+test('zonder de omgevingsvariabele is er geen uitzondering', async () => {
+  // De lijst is leeg tenzij iemand hem expliciet vult. Dit is de stand waarin
+  // elke productie-instance draait.
+  const eerder = process.env.AP_ALLOW_HOSTS;
+  delete process.env.AP_ALLOW_HOSTS;
+  const mod = await import(`../src/services/ActivityPubService.js?leeg=${Date.now()}`);
+  let fout = null;
+  try { await mod.safeFetch('http://[::1]:3060/u/w001'); } catch (e) { fout = e.message; }
+  assert.equal(fout, 'ssrf-blocked-ip');
+  process.env.AP_ALLOW_HOSTS = eerder;
+});
+
+test('een ander schema blijft hoe dan ook geweigerd', async () => {
+  assert.equal(await faalt('file:///etc/passwd'), 'ssrf-bad-scheme');
+});
Index: tools/ward-farm/farm.mjs
===================================================================
--- tools/ward-farm/farm.mjs	(revision ea211b9cdb42e4c59da937c4c2ab5162251ce374)
+++ tools/ward-farm/farm.mjs	(revision 29eb7459b738dadb3363af52947ca6358844a4e9)
@@ -33,5 +33,14 @@
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const STATE = path.join(HERE, 'state.json');
-const BASE = process.env.BASE || 'https://wards.klonkt.com';
+// GEEN TLS, en dat hoort ook zo (Barts vraag, 8-8: "waarom via https? gewoon
+// localhost ::1"). Een certificaat en een publieke hostname toevoegen aan een
+// doos die ook andermans sites draait, om honderd neptestkinderen te kunnen
+// bereiken, is de verkeerde prijs. Klonkt laat dit adres door via AP_ALLOW_HOSTS
+// -- een precies host:poort-paar, geen "loopback mag"-vlag.
+//
+// EN HET TOETST IETS DAT WE TOCH MOESTEN KUNNEN: http met HTTP-signatures, zonder
+// TLS. Dat is een echt federatiegeval (interne netwerken, onion), en tot nu toe
+// was het nergens uitgeprobeerd.
+const BASE = process.env.BASE || 'http://[::1]:3060';
 const PORT = Number(process.env.PORT || 3060);
 const N = Number(process.env.WARDS || 100);
@@ -276,5 +285,5 @@
 bewaarStaat(staat);
 
-server.listen(PORT, '127.0.0.1', () => {
+server.listen(PORT, '::1', () => {
   console.log(`[farm] ${N} wards op ${BASE} (luistert op 127.0.0.1:${PORT})`);
 });
Index: tools/ward-farm/offer-many.mjs
===================================================================
--- tools/ward-farm/offer-many.mjs	(revision ea211b9cdb42e4c59da937c4c2ab5162251ce374)
+++ tools/ward-farm/offer-many.mjs	(revision 29eb7459b738dadb3363af52947ca6358844a4e9)
@@ -25,5 +25,5 @@
 
 const slug = args.slug || 'dev';
-const farmBase = (args.base || 'https://wards.klonkt.com').replace(/\/+$/, '');
+const farmBase = (args.base || 'http://[::1]:3060').replace(/\/+$/, '');
 const van = Number(args.from || 1);
 const tot = Number(args.to || 100);
