Index: tools/ward-farm/farm.mjs
===================================================================
--- tools/ward-farm/farm.mjs	(revision 66d97b80b4d102bdf33f881136323c103204358b)
+++ tools/ward-farm/farm.mjs	(revision 66d97b80b4d102bdf33f881136323c103204358b)
@@ -0,0 +1,280 @@
+/**
+ * Een kudde ward-daemons, om de guardian met een CASELOAD te kunnen testen.
+ *
+ * Aanleiding (Bart, 8-8): ik noemde "tientallen hulpvragen bij een guardian" een
+ * randgeval en zette het op P3. Bart: "precies de jeugdzorgmedewerker." Dat is
+ * geen uitzondering maar een beroep, en alles wat we bouwden -- de wardlijst, de
+ * poortenpanelen, de hulpvragen -- is ontworpen voor iemand met twee kinderen.
+ * Deze farm is de maat die ontbrak.
+ *
+ * ECHT REMOTE, en dat is geen detail. De wards wonen op een ANDERE origin dan
+ * dev.klonkt.com. Zou je ze onder dev zelf hangen, dan ziet Klonkt ze als lokaal
+ * (het co-locatie-pad kijkt of de URI met PUBLIC_BASE_URL begint) en test je de
+ * kortsluitroute in plaats van de federatie. Dan meet je het verkeerde.
+ *
+ * Wat dit WEL is: honderd AP-actors die de FEP-633c-handshake kunnen aannemen en
+ * een hulpvraag kunnen sturen. Wat het NIET is: een Klonkt. Geen opslag van
+ * timelines, geen weergave, geen C2S. Precies genoeg om aan de andere kant van
+ * de lijn te staan.
+ *
+ * Sleutels staan in state.json en worden EEN keer gemaakt. Weggooien betekent
+ * dat elke ward een vreemde wordt voor dev, en dat de handshake opnieuw moet.
+ *
+ *   node farm.mjs                 # start op poort 3060
+ *   WARDS=100 PORT=3060 node farm.mjs
+ */
+
+import http from 'node:http';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const STATE = path.join(HERE, 'state.json');
+const BASE = process.env.BASE || 'https://wards.klonkt.com';
+const PORT = Number(process.env.PORT || 3060);
+const N = Number(process.env.WARDS || 100);
+const HOST = new URL(BASE).host;
+
+// ── Staat ────────────────────────────────────────────────────────────────
+// Een plat bestand. Dit is een testkudde, geen instance: als de staat weg is
+// begin je opnieuw, en dat hoort ook zo -- anders sluipt er toestand in waarvan
+// niemand meer weet waar hij vandaan kwam.
+
+function laadStaat() {
+  try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { wards: {} }; }
+}
+function bewaarStaat(s) {
+  fs.writeFileSync(STATE, JSON.stringify(s, null, 2));
+}
+
+const staat = laadStaat();
+
+function ward(n) {
+  const naam = `w${String(n).padStart(3, '0')}`;
+  if (!staat.wards[naam]) {
+    const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+      modulusLength: 2048,
+      publicKeyEncoding: { type: 'spki', format: 'pem' },
+      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+    });
+    staat.wards[naam] = { naam, publicKey, privateKey, guardians: [], offers: {}, log: [] };
+    bewaarStaat(staat);
+  }
+  return staat.wards[naam];
+}
+
+const uriVan = (naam) => `${BASE}/u/${naam}`;
+const naamUit = (uri) => {
+  const m = String(uri || '').match(/\/u\/(w\d{3})\/?$/);
+  return m ? m[1] : null;
+};
+
+// ── Ondertekenen ─────────────────────────────────────────────────────────
+// (request-target) host date digest -- wat Mastodon stuurt en wat Klonkt
+// verwacht. Digest is verplicht zodra er een body is: zonder ondertekende
+// digest bewijst een handtekening niets over de inhoud.
+
+async function bezorg(w, inbox, activity) {
+  const body = JSON.stringify(activity);
+  const u = new URL(inbox);
+  const date = new Date().toUTCString();
+  const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
+  const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
+  const signature = crypto.sign('sha256', Buffer.from(signingString), w.privateKey).toString('base64');
+  const sig = `keyId="${uriVan(w.naam)}#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
+  const r = await fetch(inbox, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/activity+json',
+      Accept: 'application/activity+json',
+      Date: date, Digest: digest, Signature: sig,
+    },
+    body,
+  });
+  return r.status;
+}
+
+async function inboxVan(actorUri) {
+  const r = await fetch(actorUri, { headers: { Accept: 'application/activity+json' } });
+  if (!r.ok) throw new Error(`actor ${actorUri}: ${r.status}`);
+  const doc = await r.json();
+  return doc.inbox || (doc.endpoints && doc.endpoints.sharedInbox);
+}
+
+// ── De handshake (FEP-633c 3.1) ──────────────────────────────────────────
+//
+// De kandidaat biedt aan met een Offer van Relationship{subject: ward, object:
+// kandidaat}, gericht aan de ward en aan de bestaande guardians. Elke partij
+// accepteert. Wij zijn de WARD: wij accepteren, gericht aan alle anderen, en de
+// kandidaat sluit af met de laatste Accept -- dat is de commit.
+//
+// AUTOMATISCH JA, en alleen omdat dit een testkudde is. Bij een echt kind is
+// juist dit het moment waarop iemand moet nadenken; dat staat er hier expliciet
+// bij zodat deze code nooit ergens anders terechtkomt.
+
+async function verwerkInbox(w, activity) {
+  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
+  const actor = typeof activity.actor === 'string' ? activity.actor : (activity.actor && activity.actor.id);
+  w.log.push({ at: new Date().toISOString(), type, actor });
+  if (w.log.length > 50) w.log = w.log.slice(-50);
+
+  const o = activity.object;
+  const relType = o && (Array.isArray(o.type) ? o.type[0] : o.type);
+
+  if (type === 'Offer' && relType === 'Relationship') {
+    const subject = typeof o.subject === 'string' ? o.subject : (o.subject && o.subject.id);
+    if (subject !== uriVan(w.naam)) return 'niet voor mij';
+    const kandidaat = typeof o.object === 'string' ? o.object : (o.object && o.object.id);
+    w.offers[activity.id] = { kandidaat, at: Date.now() };
+    bewaarStaat(staat);
+    // Accepteren, gericht aan iedereen die in `to` stond plus de kandidaat, zodat
+    // elke kopie van de telling dezelfde kant op loopt.
+    const aan = [...new Set([...(Array.isArray(activity.to) ? activity.to : []), kandidaat])]
+      .filter((x) => x && x !== uriVan(w.naam));
+    for (const doel of aan) {
+      try {
+        const inbox = await inboxVan(doel);
+        await bezorg(w, inbox, {
+          '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://shaer.klonkt.com/ns#' }],
+          id: `${uriVan(w.naam)}/accepts/${crypto.randomUUID()}`,
+          type: 'Accept', actor: uriVan(w.naam), to: aan, object: activity,
+        });
+      } catch (e) { w.log.push({ at: new Date().toISOString(), fout: String(e.message) }); }
+    }
+    bewaarStaat(staat);
+    return 'geaccepteerd';
+  }
+
+  if (type === 'Accept') {
+    // De laatste Accept van de kandidaat is de commit (3.1.3). Wij houden hem
+    // gewoon bij: wie ons bewaakt is het enige dat wij hoeven te weten.
+    const binnenste = o && o.object;
+    const rel = binnenste && (Array.isArray(binnenste.type) ? binnenste.type[0] : binnenste.type) === 'Relationship'
+      ? binnenste : (relType === 'Relationship' ? o : null);
+    const kandidaat = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id));
+    const wie = kandidaat || actor;
+    if (wie && !w.guardians.includes(wie)) {
+      w.guardians.push(wie);
+      bewaarStaat(staat);
+      return 'guardian erbij';
+    }
+    return 'al bekend';
+  }
+
+  if (type === 'Undo') {
+    const rel = o && (Array.isArray(o.type) ? o.type[0] : o.type) === 'Relationship' ? o : null;
+    const wie = rel && (typeof rel.object === 'string' ? rel.object : (rel.object && rel.object.id));
+    if (wie) {
+      w.guardians = w.guardians.filter((g) => g !== wie);
+      bewaarStaat(staat);
+      return 'guardian eraf';
+    }
+  }
+  return 'genegeerd';
+}
+
+// ── Documenten ───────────────────────────────────────────────────────────
+
+function actorDoc(w) {
+  const id = uriVan(w.naam);
+  const doc = {
+    '@context': [
+      'https://www.w3.org/ns/activitystreams',
+      'https://w3id.org/security/v1',
+      { shaer: 'https://shaer.klonkt.com/ns#' },
+    ],
+    id,
+    type: 'Person',
+    preferredUsername: w.naam,
+    name: `Ward ${w.naam}`,
+    inbox: `${id}/inbox`,
+    outbox: `${id}/outbox`,
+    followers: `${id}/followers`,
+    following: `${id}/following`,
+    publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: w.publicKey },
+  };
+  // 2.1: alleen aanwezig als er echt guardians zijn. Een lege lijst zou zeggen
+  // "dit kind heeft er geen", en dat is iets anders dan "nog niet gecommit".
+  if (w.guardians.length) doc['shaer:guardians'] = w.guardians;
+  return doc;
+}
+
+function stuur(res, code, body, type = 'application/activity+json') {
+  const s = typeof body === 'string' ? body : JSON.stringify(body);
+  res.writeHead(code, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(s) });
+  res.end(s);
+}
+
+// ── Server ───────────────────────────────────────────────────────────────
+
+const server = http.createServer(async (req, res) => {
+  const u = new URL(req.url, BASE);
+  const p = u.pathname;
+
+  if (p === '/.well-known/webfinger') {
+    const m = String(u.searchParams.get('resource') || '').match(/^acct:(w\d{3})@/);
+    if (!m) return stuur(res, 404, { error: 'not found' }, 'application/json');
+    const id = uriVan(m[1]);
+    return stuur(res, 200, {
+      subject: `acct:${m[1]}@${HOST}`,
+      links: [{ rel: 'self', type: 'application/activity+json', href: id }],
+    }, 'application/jrd+json');
+  }
+
+  if (p === '/status') {
+    const wards = Object.values(staat.wards);
+    return stuur(res, 200, {
+      wards: wards.length,
+      metGuardian: wards.filter((w) => w.guardians.length).length,
+      voorbeeld: wards[0] ? { naam: wards[0].naam, guardians: wards[0].guardians } : null,
+    }, 'application/json');
+  }
+
+  const mInbox = p.match(/^\/u\/(w\d{3})\/inbox$/);
+  if (mInbox && req.method === 'POST') {
+    const w = staat.wards[mInbox[1]];
+    if (!w) return stuur(res, 404, { error: 'no such ward' });
+    let body = '';
+    for await (const c of req) body += c;
+    let uit = 'onleesbaar';
+    try { uit = await verwerkInbox(w, JSON.parse(body)); }
+    catch (e) { w.log.push({ at: new Date().toISOString(), fout: String(e.message) }); }
+    // 202 hoe dan ook: een inbox die 4xx geeft op iets dat hij niet kent laat de
+    // afzender eindeloos opnieuw proberen.
+    return stuur(res, 202, { ok: true, uit });
+  }
+
+  const mOutbox = p.match(/^\/u\/(w\d{3})\/outbox$/);
+  if (mOutbox) {
+    return stuur(res, 200, {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: `${uriVan(mOutbox[1])}/outbox`, type: 'OrderedCollection', totalItems: 0, orderedItems: [],
+    });
+  }
+
+  const mActor = p.match(/^\/u\/(w\d{3})\/?$/);
+  if (mActor) {
+    const w = staat.wards[mActor[1]];
+    if (!w) return stuur(res, 404, { error: 'no such ward' });
+    return stuur(res, 200, actorDoc(w));
+  }
+
+  const mLog = p.match(/^\/u\/(w\d{3})\/log$/);
+  if (mLog) {
+    const w = staat.wards[mLog[1]];
+    return w ? stuur(res, 200, w.log, 'application/json') : stuur(res, 404, {}, 'application/json');
+  }
+
+  stuur(res, 404, { error: 'not found' }, 'application/json');
+});
+
+// De sleutels vooraf maken, zodat de eerste binnenkomende fetch niet op
+// keygen hoeft te wachten -- 100 keer 2048 bits duurt even.
+for (let i = 1; i <= N; i++) ward(i);
+bewaarStaat(staat);
+
+server.listen(PORT, '127.0.0.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 66d97b80b4d102bdf33f881136323c103204358b)
+++ tools/ward-farm/offer-many.mjs	(revision 66d97b80b4d102bdf33f881136323c103204358b)
@@ -0,0 +1,82 @@
+/**
+ * Deze Klonkt biedt zich aan als guardian bij een reeks wards (FEP-633c 3.1).
+ *
+ * Bedoeld voor de caseload-test: honderd kinderen, een guardian. Draait tegen de
+ * database van de instance zelf, zoals de andere scripts hier -- geen OAuth, geen
+ * tweede weg. Wat het doet is precies wat de PWA-knop doet, honderd keer.
+ *
+ *   DATABASE_PATH=... PUBLIC_BASE_URL=... node offer-many.mjs \
+ *     --slug dev --base https://wards.klonkt.com --from 1 --to 100
+ *
+ * ECHTE FEDERATIE, geen kortsluiting: de wards wonen op een andere origin, dus
+ * elk aanbod gaat over de lijn en wordt daar ondertekend beantwoord. Dat is het
+ * hele punt -- een test die de co-locatieroute neemt meet iets anders dan wat er
+ * in productie gebeurt.
+ *
+ * VEILIG OM OPNIEUW TE DRAAIEN: een ward die al gecommit is wordt overgeslagen,
+ * en een aanbod dat al openstaat wordt niet nog eens gestuurd. Zonder dat maakt
+ * een tweede run honderd dubbele aanbiedingen bij mensen die al ja zeiden.
+ */
+
+const args = Object.fromEntries(
+  process.argv.slice(2).join(' ').split('--').filter(Boolean)
+    .map((s) => s.trim().split(/\s+/)).map(([k, ...v]) => [k, v.join(' ') || true]),
+);
+
+const slug = args.slug || 'dev';
+const farmBase = (args.base || 'https://wards.klonkt.com').replace(/\/+$/, '');
+const van = Number(args.from || 1);
+const tot = Number(args.to || 100);
+const pauze = Number(args.pauze || 150);
+
+const AP = await import('../../src/services/ActivityPubService.js');
+const Guardianship = await import('../../src/services/guardianship/index.js');
+const dbMod = await import('../../src/config/database.js');
+const db = dbMod.default;
+
+const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+if (!site) { console.error(`geen site met slug "${slug}"`); process.exit(1); }
+
+const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+const me = AP.actorId(base, site.slug);
+console.log(`[offer] ${me} biedt zich aan bij w${String(van).padStart(3, '0')}..w${String(tot).padStart(3, '0')} op ${farmBase}`);
+
+const alBekend = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
+let gestuurd = 0, overgeslagen = 0, mislukt = 0;
+
+for (let i = van; i <= tot; i++) {
+  const naam = `w${String(i).padStart(3, '0')}`;
+  const ward = `${farmBase}/u/${naam}`;
+  if (alBekend.has(ward)) { overgeslagen++; continue; }
+
+  // Precies de vorm die handleOutbox verwacht: een Offer van een
+  // Relationship{subject: ward, object: ik}. Door hem via de outbox te sturen
+  // loopt dit langs dezelfde code als de knop in de PWA -- er is geen tweede pad
+  // naar hetzelfde besluit, en dat is de les van vandaag.
+  const activity = {
+    type: 'Offer',
+    to: [ward],
+    object: {
+      type: 'Relationship',
+      subject: ward,
+      object: me,
+      relationship: 'https://shaer.klonkt.com/ns#guardian',
+    },
+  };
+
+  try {
+    const uit = await AP.ingestOutboxActivity(site, { id: site.owner_id, username: slug }, activity);
+    if (uit && (uit.status === 201 || uit.status === 200)) gestuurd++;
+    else { mislukt++; console.warn(`  ${naam}: ${uit && uit.status} ${uit && uit.error}`); }
+  } catch (e) {
+    mislukt++;
+    console.warn(`  ${naam}: ${e.message}`);
+  }
+  // Een adempauze. Honderd handshakes tegelijk zegt meer over de bezorgwachtrij
+  // dan over het paneel, en dit script is er om het paneel te kunnen bekijken.
+  if (pauze) await new Promise((r) => setTimeout(r, pauze));
+}
+
+console.log(`[offer] verstuurd ${gestuurd}, overgeslagen ${overgeslagen}, mislukt ${mislukt}`);
+console.log('[offer] de commit valt pas als elke ward heeft geaccepteerd; kijk over een minuut met listWards.');
+process.exit(0);
