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);
