Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 984e0c6fb2d1b86cfe7692a1bfcb58441fa8519a)
+++ src/services/ActivityPubService.js	(revision db81e566b1d11e0ec09d9824f11ad3ffebab07d3)
@@ -111,4 +111,8 @@
   voteOnPoll, voteOnRemotePoll,
 };
+// Stap 9 (shaer-drc): de inbox woont in ap-inbox.js. De schakelkast krijgt
+// onderaan zijn tweeendertig werktuigen via wireInbox.
+import { handleInbox, wireInbox } from './ap-inbox.js';
+export { handleInbox };
 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
 export { AP_CONTEXT, actorId, noteId, guessMediaType };
@@ -234,14 +238,4 @@
 }
 
-
-/**
- * Welke objectsoorten deze inbox in de tijdlijn opneemt.
- *
- * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
- * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
- * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
- * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
- */
-const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
 
 /**
@@ -1807,844 +1801,4 @@
     return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
   } catch { return null; }
-}
-
-/**
- * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
- *
- * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
- * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
- * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
- * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
- * binnen, zonder dat iemand een fout zag.
- *
- * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
- * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
- * bezorgde inhoud NIET en halen het object op bij de bron.
- *
- * Vier voorwaarden, en geen ervan is optioneel:
- *
- *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
- *     dereferencen -- het object is weg -- dus die blijft geweigerd.
- *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
- *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
- *     attributedTo alles kan beweren.
- *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
- *     levert een doorsturer een echt id met verdraaide inhoud.
- *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
- *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
- */
-/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
- *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
- *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
- *  doorstuurt; iets anders is dat niet. */
-function knownNoteUri(uri) {
-  if (!uri || typeof uri !== 'string') return false;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  try {
-    if (base && uri.startsWith(`${base}/ap/notes/`)) {
-      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
-      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
-      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
-    }
-    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
-    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
-    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
-    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
-  } catch { /* bij twijfel niet ophalen */ }
-  return false;
-}
-
-/**
- * Onthoud dat we dit bericht al eens bezorgd kregen.
- *
- * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
- * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
- * stelt voordat er iets bij de bron wordt opgehaald.
- *
- * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
- * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
- * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
- * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
- * schrijvers die je zelf volgt.
- */
-const SEEN_NOTES_DAYS = 30;
-let _seenSinceSnoei = 0;
-function rememberNoteUri(uri) {
-  if (!uri || typeof uri !== 'string') return;
-  try {
-    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
-    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
-    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
-    // ouder is dan een maand beantwoordt geen enkele vraag meer.
-    if (++_seenSinceSnoei >= 500) {
-      _seenSinceSnoei = 0;
-      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
-      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
-    }
-  } catch { /* niet fataal */ }
-}
-const isFollowedActor = (uri) => {
-  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
-};
-
-// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
-// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
-// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
-const _derefMiss = new Map();
-const DEREF_MISS_MS = 30 * 60 * 1000;
-function derefRecentlyFailed(uri) {
-  const t = _derefMiss.get(uri);
-  if (t === undefined) return false;
-  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
-  return true;
-}
-function noteDerefFailure(uri) {
-  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
-    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
-    for (const [k] of oud) _derefMiss.delete(k);
-  }
-  _derefMiss.set(uri, Date.now());
-}
-
-async function dereferenceForwarded(act, claimedActor, type, slugParam) {
-  // Every exit states its reason. Five of the six used to return silently, so a
-  // rejection count could not be told apart from a narrowing that closed too far
-  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
-  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
-  const skipped = (reason, detail) => {
-    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
-    return null;
-  };
-  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
-  const o = act && act.object;
-  const objId = typeof o === 'string' ? o : (o && o.id);
-  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
-  try {
-    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
-  } catch { return skipped('unparsable id', objId); }
-  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
-  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
-  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
-  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
-  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
-  // niets aan legitiem verkeer waarvan we de ouder kennen.
-  const parent = typeof o === 'object' && o
-    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
-    : null;
-  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
-  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
-  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
-  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
-  // reden als bij fetchActor sinds efe5633.
-  let fetched = await apGetJson(objId).catch(() => null);
-  if (!fetched || fetched.id !== objId) {
-    // The signer used to be slugParam, which is null on the shared inbox — and
-    // that is where forwarded traffic lands, because we advertise a sharedInbox.
-    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
-    // secure mode could never be dereferenced at all. Same fix verifyRequest got
-    // in shaer-afq: any local actor is a valid signer.
-    const asSlug = slugParam || anySigningSlug();
-    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
-  }
-  const attributed = fetched && (typeof fetched.attributedTo === 'string'
-    ? fetched.attributedTo
-    : (fetched.attributedTo && fetched.attributedTo.id));
-  if (!fetched || fetched.id !== objId) {
-    noteDerefFailure(objId);
-    return skipped('fetch failed', objId);
-  }
-  if (attributed !== claimedActor) {
-    // Not a transport hiccup: the source itself says someone else wrote this.
-    noteDerefFailure(objId);
-    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
-  }
-  return fetched;
-}
-
-// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
-export async function handleInbox(req, slugParam, preVerified = null) {
-  const act = req.body || {};
-  const type = act.type;
-  // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
-  // ignored inbox hits so an operator can see who is probing their fediverse inbox.
-  const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
-  const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-  // preVerified is the loopback (see deliverToActor): a delivery between two
-  // actors on THIS instance never crosses a socket, so there is no signature to
-  // check — but we do know who signed, because we signed it. Handing that in
-  // keeps everything below identical, including the actor-versus-signer check,
-  // which is exactly the check that must not be skipped for being local.
-  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
-
-  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
-  // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
-  // forged replies/likes/follows/timeline posts). GET/discovery stays open.
-  const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-  // Blocked actor/domain → silently drop (202, don't reveal the block).
-  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
-  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
-  if (GATED.includes(type)) {
-    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
-    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
-    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
-    let forwarded = null;
-    if (verified && claimedActor && verified.id !== claimedActor) {
-      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
-      if (forwarded) {
-        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
-        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
-      }
-    }
-    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
-      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
-      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
-      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
-      // of geldig ondertekend door iemand anders.
-      const reden = verified ? '(signer mismatch)'
-        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
-        : '(no signature)';
-      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
-      return 401;
-    }
-    // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
-    // from an actor that guards someone here restores it to active for those
-    // wards and cancels any lapse running against it, before the activity is
-    // even looked at. Signature-gated on purpose: an unverified claim of
-    // being gran must not wake gran up.
-    try {
-      const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
-      if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
-      for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
-    } catch { /* availability is never load-bearing for delivery */ }
-  }
-
-  // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
-  // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
-  if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
-    if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
-  }
-
-  // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
-  // Accept/Reject answers an offer a local guardian sent. Anything the
-  // guardianship module does not recognize falls through to the old paths.
-  // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
-  // must be seen BEFORE the generic Undo branch below, which only knows about
-  // Follow/Like/Announce and would swallow it with a 202.
-  if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
-    // Every LOCAL party this activity is addressed to gets its own copy of the
-    // handshake (a ward and a co-guardian may both live here). Gather candidate
-    // local slugs from the inbox owner, the `to` list, and the ward.
-    // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de
-    // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's
-    // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht
-    // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en
-    // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit
-    // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze
-    // eigen basis begint en dat de site echt bestaat.
-    const cand = new Set();
-    if (slugParam) cand.add(slugParam);
-    for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
-      if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }
-    }
-    if (type === 'Offer' || type === 'Undo') {
-      const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
-      if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }
-    }
-    let consumed = false;
-    for (const slug of cand) {
-      const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-      if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
-    }
-    if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
-  }
-
-  // A moderation report (Flag) about our content — store it for the targeted site's owner
-  // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
-  if (type === 'Flag') {
-    const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
-    const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
-    let targetSlug = null;
-    const noteIds = [];
-    for (const u of objectUris) {
-      const s = localSlugOf(u);             // one of OURS -- host meegewogen
-      if (s) { targetSlug = targetSlug || s; continue; }
-      const pid = postIdFromNoteUrl(u, base); // one of our notes?
-      if (pid) noteIds.push(pid);
-    }
-    if (!targetSlug && noteIds.length) {
-      try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }
-    }
-    if (!targetSlug) return 202; // not about us / can't tell → drop
-    // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
-    const ai = actorInfo(verified || null, claimedActor);
-    try {
-      db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
-        .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
-      console.log('[AP] report received for', targetSlug, 'from', claimedActor);
-    } catch { /* ignore */ }
-    return 202;
-  }
-
-  // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
-  // a Move often arrives seconds before the new actor's re-Follow wave, and the
-  // swap below must not race our own outgoing Follow of the target.
-  if (type === 'Move') {
-    return handleMoveInbox(act, { verifiedActor: claimedActor });
-  }
-
-  if (type === 'Follow') {
-    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
-    //
-    // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
-    // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
-    // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
-    // ongeluk meenemen.
-    //
-    // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
-    // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
-    const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
-    if (who && libSlug) {
-      const remote = await fetchActor(who);
-      if (!remote || !remote.inbox) return 202;
-      const fi = actorInfo(remote, who);
-      luisteraars.voegToe(libSlug, {
-        actorUri: who, inbox: remote.inbox,
-        sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
-        name: fi.name, handle: fi.handle, icon: fi.icon,
-      });
-      const keys = getOrCreateKeys(libSlug);
-      const accept = {
-        '@context': AP_CONTEXT,
-        id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
-        type: 'Accept', actor: actorId(base, libSlug), object: act,
-      };
-      deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
-        .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
-      console.log('[AP] library follow from', who, '->', libSlug);
-      return 202;
-    }
-    // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
-    // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
-    // kon een Follow op andermans actor met dezelfde padstaart hier een volger
-    // opleveren.
-    const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
-    if (!who || !slug) return 400;
-    const remote = await fetchActor(who);
-    if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
-    const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
-    const fi = actorInfo(remote, who);   // cache display for the friends list (shaer-aa3)
-    // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
-    // follow is gated. A committed guardian's own Follow is auto-accepted
-    // (it needs no gate); anyone else is held pending for guardian approval.
-    // Free actors / normal sites have no guardians → fall through, unchanged.
-    const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
-    if (wardGuardians.length && !wardGuardians.includes(who)) {
-      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
-      Guardianship.follows.recordPending(slug, {
-        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
-        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
-      });
-      // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
-      // gated follow to its guardians for approval. A LOCAL guardian gets a
-      // push and reads /guardian directly; a REMOTE guardian gets an
-      // Offer(Follow) delivered so its instance stores a copy (same distributed
-      // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
-      const wardActor = actorId(base, slug);
-      const wardKeys = getOrCreateKeys(slug);
-      const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
-      // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
-      // every guardian. The ONLY admissible evidence is a request like this
-      // one going unanswered; recordRequest itself skips a declared absence.
-      for (const g of wardGuardians) {
-        try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
-      }
-      for (const g of wardGuardians) {
-        // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
-        // ignores the host (an /ap/users/x path on a remote host is someone
-        // else's actor), so also require our base + an existing local site.
-        const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
-        const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
-        if (isLocal) {
-          const L = pushLang(gslug);
-          // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van
-          // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de
-          // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie
-          // er vraagt, en om wie het gaat (shaer-p729).
-          pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folin_t'), body: i18nT(L, 'push.n_guard_folin_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
-        } else {
-          fetchActor(g).then((ga) => {
-            const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
-            if (!inbox) return;
-            const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
-            const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:decisive': beslissend2 };
-            deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
-          }).catch(() => {});
-        }
-      }
-      console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
-      return 202;
-    }
-    // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt
-    // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde
-    // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op
-    // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander
-    // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.
-    // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.
-    const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);
-    if (ownerGate && ownerGate.approve_followers) {
-      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
-      Guardianship.follows.recordPending(slug, {
-        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
-        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',
-      });
-      const L = pushLang(slug);
-      pushEvent(slug, {
-        type: 'follow',
-        title: i18nT(L, 'push.n_folreq_t'),
-        body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),
-        url: `${pushPrefix(slug)}/connect`,
-      });
-      console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');
-      return 202;
-    }
-    fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
-    try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
-    { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
-    const me = actorId(base, slug);
-    const keys = getOrCreateKeys(slug);
-    const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
-    deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
-    // Auto-backfill: send our recent posts as Create so the instance has our history
-    // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
-    // Mastodon dedupes notes per-instance, so re-filling an instance that already has
-    // a follower of ours is wasted work (and won't re-populate the new follower's
-    // timeline anyway). Deliver to the shared inbox (instance-level) when present.
-    // Sync insert+check (no await between) → no interleave race with concurrent Follows.
-    const instanceFilled = sharedInbox &&
-      db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
-        .get(slug, sharedInbox, who);
-    if (!instanceFilled) {
-      backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
-    }
-    console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
-    return 202;
-  }
-  // Een luisteraar die weggaat, hoort meteen weg te zijn.
-  if (type === 'Undo' && act.object && act.object.type === 'Follow') {
-    const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
-    const libSlug = libraryOwnerSlug(doel);
-    const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
-      console.log('[AP] library unfollow from', wie, '->', libSlug);
-      return 202;
-    }
-  }
-
-  if (type === 'Undo' && act.object) {
-    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    const ot = act.object.type;
-    if (ot === 'Follow') {
-      const obj = act.object.object;
-      const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
-      if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
-      return 202;
-    }
-    if (ot === 'Like' || ot === 'Announce') {
-      const tgt = act.object.object;
-      const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
-      if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
-      return 202;
-    }
-    return 202;
-  }
-
-  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
-  // Our OWN activity is already stored via ap_outbox: don't store it twice.
-  // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
-  // machine". The old reading dropped every activity between two sites on one
-  // instance, so a note from a co-located guardian to its ward was accepted
-  // with a 202 and then quietly thrown away: no mention, no away, no help
-  // request. Neighbours are not us (Robins regel, 29-7: on this machine
-  // everything behaves as if every Klonkt were somewhere else).
-  const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
-
-  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
-  if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
-    const o = act.object;
-    // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
-    // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
-    // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
-    if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
-      const seg = postIdFromNoteUrl(o.inReplyTo, base);
-      if (seg && localPostExists(seg)) {
-        const rec = recordPollBallot(seg, actorUri, o.name);
-        if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
-      }
-    }
-    const tgt = findThreadTarget(o.inReplyTo, base);
-    if (tgt && actorUri && !isLocalActor) {
-      const ai = actorInfo(await resolveActor(actorUri), actorUri);
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
-      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
-      console.log('[AP] reply', actorUri, '→', tgt.post_id);
-      // A reply is a post too: Berichten renders it the way de Krant renders a
-      // timeline row, so it needs the same media and the same quote/preview card.
-      {
-        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
-        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
-        const mj = mediaFromNote(o);
-        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
-        resolveCard(o).then((c) => {
-          if (!c) return;
-          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
-          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
-        }).catch(() => { /* best-effort */ });
-      }
-      {
-        // Private (followers/direct) replies push as a DM ping WITHOUT content
-        // (the push service should never carry private text, design decision);
-        // public replies carry a short snippet.
-        const ctx = pushPostCtx(tgt.post_id);
-        const vis = noteVisibility(o);
-        const priv = vis === 'direct' || vis === 'followers';
-        if (ctx) {
-          const L = pushLang(ctx.site);
-          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-          if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
-          else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
-        }
-      }
-      return 202;
-    }
-    // Home timeline (client): a top-level post from an account we follow.
-    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
-      let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
-      if (subs.length) {
-        const ai = actorInfo(await resolveActor(actorUri), actorUri);
-        const { html, atts: _atts, url: _url } = timelineFields(o);
-        const media = JSON.stringify(_atts);
-        const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
-        // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
-        // incoming posts to the fediverse — that flooded followers. Boosting to the
-        // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
-        // the timeline).
-        for (const s of subs) {
-          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));
-          // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
-          if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
-          // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
-          { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
-          storeAuthorEmoji(o.id, s.slug, ai);   // custom-emoji display name for the byline
-
-          // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
-          { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
-          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
-        }
-        // FEP-044f embedded quote card: resolve the quoted post out of band so
-        // the inbox response is not blocked on a remote fetch. Best-effort.
-        if (quoteHrefOf(o)) {
-          const slugs = subs.map((s) => s.slug);
-          resolveQuote(o).then((qj) => {
-            if (!qj) return;
-            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
-          }).catch(() => { /* best-effort */ });
-        } else {
-          // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
-          // thumbnail-only. Also out of band, and stored for everyone; the gate
-          // that decides who may SEE it is applied at serve time (§5.3-style
-          // gated feature, see the inbox read).
-          const slugs = subs.map((s) => s.slug);
-          resolveExternalEmbed(o.content).then((ej) => {
-            if (!ej) return;
-            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }
-          }).catch(() => { /* best-effort */ });
-        }
-        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
-      }
-    }
-    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
-    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
-    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
-    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
-    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
-    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
-    // moet iemand zijn die je zelf bent gaan volgen.
-    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
-      rememberNoteUri(o.id);
-    }
-    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
-    // above): store a mention notification for each of our actors named in the Mention tags.
-    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
-    // someone else's actor, not ours.
-    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
-    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
-    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
-    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
-    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
-    if (actorUri && !isLocalActor) {
-      const mark = Guardianship.help.parseMarker(o);
-      if (mark) {
-        const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
-        Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
-        wakeGuardian(slug);   // een mede-guardian pakte iets op: het paneel hoort het meteen
-        console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
-      }
-    }
-    if (actorUri && !isLocalActor && o.id) {
-      const slugs = localMentionSlugs(o.tag, base);
-      if (slugs.length) {
-        const ai = actorInfo(await resolveActor(actorUri), actorUri);
-        const html = HtmlSanitizerService.sanitize(o.content || '');
-        // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
-        // flag is stored so the Guardian PWA's message centre can list it.
-        const help = Guardianship.isHelpRequest(o);
-        const wave = Guardianship.isWave(o);
-        const hasG = Guardianship.objectHasGuardians(o);   // §2.2 hint, register-only
-        // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
-        // same direct note the mention below stores (so the kid also reads it
-        // as an ordinary message). Recorded only from an actual guardian of
-        // the addressed ward, and only with an end: an absence without an end
-        // is logged and dropped, never guessed.
-        if (Guardianship.availability.isAway(o)) {
-          const until = Guardianship.availability.parseEndTime(o.endTime);
-          for (const slug of slugs) {
-            const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
-            if (!isG) continue;
-            if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
-            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);
-              wakeGuardian(slug);   // het kind vroeg om een poort
-              console.log('[AP] gate request', req.feature, actorUri, '→', slug);
-            }
-          }
-        }
-        for (const slug of slugs) {
-          try {
-            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
-                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
-              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
-                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
-            if (r.changes) {
-              // The quote / link-preview card resolves out of band (a remote
-              // fetch), exactly as it does for a timeline post, so the inbox
-              // answer is never blocked on it.
-              resolveCard(o).then((c) => {
-                if (!c) return;
-                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
-                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
-              }).catch(() => { /* best-effort */ });
-              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
-              const vis = noteVisibility(o);
-              const priv = vis === 'direct' || vis === 'followers';
-              const L = pushLang(slug);
-              const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-              // Same privacy rule as replies: private mentions push without content.
-              // A help request pushes as its own alert type, aimed at the
-              // Guardian PWA's message centre.
-              if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
-              else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
-              else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
-            }
-          } catch { /* ignore */ }
-        }
-      }
-    }
-    return 202;
-  }
-  // A remote post we cached was edited upstream → refresh our cached copy. This is the
-  // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
-  // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
-  // edit A's note (the signature gate guarantees claimedActor == the verified signer).
-  if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
-    const o = act.object;
-    if (o.id && claimedActor) {
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      const media = mediaFromNote(o);
-      try {
-        // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
-        // rename keeps the same AP id but changes the human url, so without this the cached
-        // post would keep linking to the old, now-dead URL.
-        const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
-          .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
-        if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
-        // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
-        // site keeps its own `voted` state while the counts/closed update to the new totals.
-        const poll = parsePoll(o);
-        if (poll) {
-          const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
-          const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
-          for (const rw of rows) {
-            let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
-            upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
-          }
-        }
-      } catch { /* ignore */ }
-      // If this note is a cached fediverse reply on one of our posts, refresh its text too.
-      try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
-    }
-    return 202;
-  }
-  if (type === 'Like' || type === 'Announce') {
-    const tgt = act.object;
-    const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
-    const pid = postIdFromNoteUrl(objUrl, base);
-    if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
-      // A boost/like of a non-public post is dropped, not stored: nobody
-      // outside the audience should even hold it (shaer-tqc hardening).
-      const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
-      if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
-        console.log('[AP] dropped', type, 'on non-public post', pid);
-        return;
-      }
-      const ai = actorInfo(await resolveActor(actorUri), actorUri);
-      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
-      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
-      {
-        const ctx = pushPostCtx(pid);
-        if (ctx) {
-          const L = pushLang(ctx.site);
-          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-          if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
-          else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
-        }
-      }
-    } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
-      // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
-      // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
-      // re-announcing an incoming Announce would cascade boosts across the network).
-      let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
-      if (subs.length) {
-        const bn = await fetchNoteAP(objUrl);
-        if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
-          const origUri = actorUriOf(bn.attributedTo);
-          // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
-          // author is blocked — otherwise a block is bypassed via someone else's boost.
-          if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
-          const oai = actorInfo(await resolveActor(origUri), origUri);
-          const html = HtmlSanitizerService.sanitize(bn.content || '');
-          const media = mediaFromNote(bn);
-          const booster = actorInfo(await resolveActor(actorUri), actorUri);
-          for (const s of subs) {
-            // published = now → the boost shows as fresh activity at the top (Mastodon shows
-            // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
-            // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
-            let inserted = false;
-            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }
-            if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
-            storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
-            // A boost carries the same renderable tags as a Create: capture the
-            // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
-            // 044f) so boosted posts render like any other, not as raw shortcodes.
-            { const ej = extractEmojiTags(bn.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, bn.id, s.slug); } catch { /* ignore */ } } }
-            { const lj = extractLinkJson(bn); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, bn.id, s.slug); } catch { /* ignore */ } } }
-          }
-          // FEP-044f: resolve the embedded quote card for a boosted post too
-          // (out of band, best-effort, so it does not block the inbox response).
-          if (quoteHrefOf(bn)) {
-            const slugs = subs.map((s) => s.slug);
-            resolveQuote(bn).then((qj) => {
-              if (!qj) return;
-              for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, bn.id, sl); } catch { /* ignore */ } }
-            }).catch(() => { /* best-effort */ });
-          }
-          console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
-        }
-      }
-    }
-    return 202;
-  }
-  if (type === 'Delete') {
-    // A remote note was deleted upstream → drop it from replies AND the timeline.
-    // Scope to the SIGNING actor so actor B can't delete actor A's content (the
-    // signature gate guarantees claimedActor == the verified signer here).
-    const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-    if (oid && claimedActor) {
-      try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
-      try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
-      // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
-      // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
-      // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
-      // to A's posts).
-      try {
-        let sameHost = false;
-        try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
-        if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
-      } catch { /* ignore */ }
-    }
-    return 202;
-  }
-  // Accept/Reject of a Follow WE sent (client side).
-  if (type === 'Accept' && act.object) {
-    const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-    let raak = 0;
-    if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }
-    // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de
-    // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een
-    // EIGEN id voor, in ONZE namespace:
-    //
-    //   wij stuurden   .../ap/users/dev#follow-1786161977286-bb2de32f
-    //   Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...
-    //
-    // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op
-    // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op
-    // die pas opviel toen er nooit iets binnenkwam.
-    //
-    // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is
-    // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een
-    // rij die nog op pending staat wordt geraakt, dus dit kan niets anders
-    // openzetten dan een follow die wij zelf hebben verstuurd.
-    //
-    // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de
-    // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten
-    // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is
-    // onze eigen actor-URI.
-    let mij = slugParam;
-    if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);
-    if (!raak && mij && actorUri) {
-      try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }
-    }
-    // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te
-    // zien te zijn in plaats van als succes voorbij te komen.
-    console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');
-    // The moment a friendship exists is the moment the history comes along
-    // (Robins besluit, 30-7): delivery cannot reach into the past, so the
-    // fresh follower pulls the outbox, signed, and the other side now serves
-    // the friends-only posts too.
-    if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
-    return 202;
-  }
-  if (type === 'Reject' && act.object) {
-    const who = actorUri;
-    if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
-    return 202;
-  }
-
-  // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil
-  // tussen een soort die we bewust overslaan en een die we niet kennen -- en
-  // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt
-  // waar deze inbox alleen Note, Article en Question aanneemt.
-  const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);
-  console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',
-    'from', ip, 'by', claimedActor || '?', '(ignored)');
-  return 202;
 }
 
@@ -5175,4 +4329,17 @@
 // telling, de id-staart, de verhuisweigering en de attributedTo-lezer.
 wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf });
+// De schakelkast (stap 9): de lijst is bewust lang -- hij is de kaart van wat
+// de inbox aanraakt, en elke naam die eraf gaat is een cluster dat zelf
+// verhuisd is.
+wireInbox({
+  actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
+  belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
+  findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
+  isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
+  localPostExists, localSlugOf, mediaFromNote, noteVisibility,
+  postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
+  resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
+  storeAuthorEmoji, timelineFields, wakeGuardian,
+});
 
 export default {
Index: src/services/ap-inbox.js
===================================================================
--- src/services/ap-inbox.js	(revision db81e566b1d11e0ec09d9824f11ad3ffebab07d3)
+++ src/services/ap-inbox.js	(revision db81e566b1d11e0ec09d9824f11ad3ffebab07d3)
@@ -0,0 +1,901 @@
+/**
+ * ap-inbox.js — de inbox (stap 9 van shaer-drc).
+ *
+ * Het hart van de federatie-ontvangst: handleInbox (de grote switch over
+ * Follow, Accept, Undo, Create, Like, Announce, Delete, Update, Move, Flag en
+ * Block), de her-verificatie van doorgestuurde activiteiten
+ * (dereferenceForwarded, shaer-s8k) en de kleine kas eromheen (bekende notes,
+ * geziene notes, recente ophaal-missers).
+ *
+ * De inbox is de SCHAKELKAST van de dienst: hij raakt vrijwel elk cluster.
+ * Wat al een eigen module heeft komt statisch binnen (transport, tijdlijn,
+ * peilingen, volgwinkel, guardianship, ap-core); de tweeendertig werktuigen
+ * die nog in de dienstlaag wonen komen via wireInbox. Die lijst is bewust
+ * lang en expliciet -- hij IS de kaart van wat de inbox aanraakt, en elke
+ * naam die er ooit afgaat is een cluster dat zelf verhuisd is.
+ * De §5.3-goedkeuring (handleFollowApprovalInbox) blijft bij zijn
+ * guardian-broers in de dienst, zoals gateOutgoingFollow bij stap 7.
+ */
+import db from '../config/database.js';
+import HtmlSanitizerService from './HtmlSanitizerService.js';
+import * as Guardianship from './guardianship/index.js';
+import { t as i18nT } from './i18n.js';
+import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
+import {
+  verifyRequest, fetchActor, deliver, deliverWithRetry, signedGetJson,
+  apGetJson, anySigningSlug, getOrCreateKeys,
+} from './ap-transport.js';
+import { tlStmts, extractEmojiTags, extractLinkJson, quoteHrefOf } from './ap-timeline.js';
+import { parsePoll, recordPollBallot } from './ap-polls.js';
+import { fwStmts } from './ap-following.js';
+
+/**
+ * Welke objectsoorten deze inbox in de tijdlijn opneemt.
+ *
+ * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
+ * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
+ * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
+ * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
+ */
+const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
+
+// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
+let actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
+  belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
+  findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
+  isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
+  localPostExists, localSlugOf, mediaFromNote, noteVisibility,
+  postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
+  resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
+  storeAuthorEmoji, timelineFields, wakeGuardian;
+export function wireInbox(deps) {
+  ({ actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
+    belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
+    findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
+    isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug,
+    localMentionSlugs, localPostExists, localSlugOf, mediaFromNote,
+    noteVisibility, postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx,
+    pushPrefix, resolveCard, resolveExternalEmbed, resolveQuote, rid,
+    slugFromActorUrl, storeAuthorEmoji, timelineFields, wakeGuardian } = deps);
+}
+
+/**
+ * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
+ *
+ * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
+ * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
+ * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
+ * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
+ * binnen, zonder dat iemand een fout zag.
+ *
+ * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
+ * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
+ * bezorgde inhoud NIET en halen het object op bij de bron.
+ *
+ * Vier voorwaarden, en geen ervan is optioneel:
+ *
+ *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
+ *     dereferencen -- het object is weg -- dus die blijft geweigerd.
+ *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
+ *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
+ *     attributedTo alles kan beweren.
+ *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
+ *     levert een doorsturer een echt id met verdraaide inhoud.
+ *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
+ *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
+ */
+/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
+ *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
+ *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
+ *  doorstuurt; iets anders is dat niet. */
+function knownNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return false;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  try {
+    if (base && uri.startsWith(`${base}/ap/notes/`)) {
+      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
+      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
+      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
+    }
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
+    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
+    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
+    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
+  } catch { /* bij twijfel niet ophalen */ }
+  return false;
+}
+
+/**
+ * Onthoud dat we dit bericht al eens bezorgd kregen.
+ *
+ * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
+ * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
+ * stelt voordat er iets bij de bron wordt opgehaald.
+ *
+ * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
+ * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
+ * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
+ * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
+ * schrijvers die je zelf volgt.
+ */
+const SEEN_NOTES_DAYS = 30;
+let _seenSinceSnoei = 0;
+function rememberNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return;
+  try {
+    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
+    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
+    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
+    // ouder is dan een maand beantwoordt geen enkele vraag meer.
+    if (++_seenSinceSnoei >= 500) {
+      _seenSinceSnoei = 0;
+      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
+      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
+    }
+  } catch { /* niet fataal */ }
+}
+const isFollowedActor = (uri) => {
+  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
+};
+
+// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
+// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
+// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
+const _derefMiss = new Map();
+const DEREF_MISS_MS = 30 * 60 * 1000;
+function derefRecentlyFailed(uri) {
+  const t = _derefMiss.get(uri);
+  if (t === undefined) return false;
+  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
+  return true;
+}
+function noteDerefFailure(uri) {
+  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
+    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
+    for (const [k] of oud) _derefMiss.delete(k);
+  }
+  _derefMiss.set(uri, Date.now());
+}
+
+async function dereferenceForwarded(act, claimedActor, type, slugParam) {
+  // Every exit states its reason. Five of the six used to return silently, so a
+  // rejection count could not be told apart from a narrowing that closed too far
+  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
+  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
+  const skipped = (reason, detail) => {
+    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
+    return null;
+  };
+  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
+  const o = act && act.object;
+  const objId = typeof o === 'string' ? o : (o && o.id);
+  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
+  try {
+    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
+  } catch { return skipped('unparsable id', objId); }
+  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
+  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
+  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
+  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
+  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
+  // niets aan legitiem verkeer waarvan we de ouder kennen.
+  const parent = typeof o === 'object' && o
+    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
+    : null;
+  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
+  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
+  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
+  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
+  // reden als bij fetchActor sinds efe5633.
+  let fetched = await apGetJson(objId).catch(() => null);
+  if (!fetched || fetched.id !== objId) {
+    // The signer used to be slugParam, which is null on the shared inbox — and
+    // that is where forwarded traffic lands, because we advertise a sharedInbox.
+    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
+    // secure mode could never be dereferenced at all. Same fix verifyRequest got
+    // in shaer-afq: any local actor is a valid signer.
+    const asSlug = slugParam || anySigningSlug();
+    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
+  }
+  const attributed = fetched && (typeof fetched.attributedTo === 'string'
+    ? fetched.attributedTo
+    : (fetched.attributedTo && fetched.attributedTo.id));
+  if (!fetched || fetched.id !== objId) {
+    noteDerefFailure(objId);
+    return skipped('fetch failed', objId);
+  }
+  if (attributed !== claimedActor) {
+    // Not a transport hiccup: the source itself says someone else wrote this.
+    noteDerefFailure(objId);
+    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
+  }
+  return fetched;
+}
+
+// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
+export async function handleInbox(req, slugParam, preVerified = null) {
+  const act = req.body || {};
+  const type = act.type;
+  // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
+  // ignored inbox hits so an operator can see who is probing their fediverse inbox.
+  const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
+  const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
+  // preVerified is the loopback (see deliverToActor): a delivery between two
+  // actors on THIS instance never crosses a socket, so there is no signature to
+  // check — but we do know who signed, because we signed it. Handing that in
+  // keeps everything below identical, including the actor-versus-signer check,
+  // which is exactly the check that must not be skipped for being local.
+  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
+
+  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
+  // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
+  // forged replies/likes/follows/timeline posts). GET/discovery stays open.
+  const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
+  // Blocked actor/domain → silently drop (202, don't reveal the block).
+  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
+  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
+  if (GATED.includes(type)) {
+    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
+    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
+    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
+    let forwarded = null;
+    if (verified && claimedActor && verified.id !== claimedActor) {
+      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
+      if (forwarded) {
+        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
+        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
+      }
+    }
+    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
+      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
+      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
+      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
+      // of geldig ondertekend door iemand anders.
+      const reden = verified ? '(signer mismatch)'
+        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
+        : '(no signature)';
+      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
+      return 401;
+    }
+    // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
+    // from an actor that guards someone here restores it to active for those
+    // wards and cancels any lapse running against it, before the activity is
+    // even looked at. Signature-gated on purpose: an unverified claim of
+    // being gran must not wake gran up.
+    try {
+      const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
+      if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
+      for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
+    } catch { /* availability is never load-bearing for delivery */ }
+  }
+
+  // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
+  // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
+  if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
+    if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
+  }
+
+  // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
+  // Accept/Reject answers an offer a local guardian sent. Anything the
+  // guardianship module does not recognize falls through to the old paths.
+  // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
+  // must be seen BEFORE the generic Undo branch below, which only knows about
+  // Follow/Like/Announce and would swallow it with a 202.
+  if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
+    // Every LOCAL party this activity is addressed to gets its own copy of the
+    // handshake (a ward and a co-guardian may both live here). Gather candidate
+    // local slugs from the inbox owner, the `to` list, and the ward.
+    // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de
+    // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's
+    // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht
+    // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en
+    // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit
+    // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze
+    // eigen basis begint en dat de site echt bestaat.
+    const cand = new Set();
+    if (slugParam) cand.add(slugParam);
+    for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
+      if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }
+    }
+    if (type === 'Offer' || type === 'Undo') {
+      const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
+      if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }
+    }
+    let consumed = false;
+    for (const slug of cand) {
+      const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+      if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
+    }
+    if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
+  }
+
+  // A moderation report (Flag) about our content — store it for the targeted site's owner
+  // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
+  if (type === 'Flag') {
+    const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
+    const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
+    let targetSlug = null;
+    const noteIds = [];
+    for (const u of objectUris) {
+      const s = localSlugOf(u);             // one of OURS -- host meegewogen
+      if (s) { targetSlug = targetSlug || s; continue; }
+      const pid = postIdFromNoteUrl(u, base); // one of our notes?
+      if (pid) noteIds.push(pid);
+    }
+    if (!targetSlug && noteIds.length) {
+      try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }
+    }
+    if (!targetSlug) return 202; // not about us / can't tell → drop
+    // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
+    const ai = actorInfo(verified || null, claimedActor);
+    try {
+      db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
+        .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
+      console.log('[AP] report received for', targetSlug, 'from', claimedActor);
+    } catch { /* ignore */ }
+    return 202;
+  }
+
+  // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
+  // a Move often arrives seconds before the new actor's re-Follow wave, and the
+  // swap below must not race our own outgoing Follow of the target.
+  if (type === 'Move') {
+    return handleMoveInbox(act, { verifiedActor: claimedActor });
+  }
+
+  if (type === 'Follow') {
+    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
+    // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
+    //
+    // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
+    // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
+    // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
+    // ongeluk meenemen.
+    //
+    // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
+    // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
+    const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
+    if (who && libSlug) {
+      const remote = await fetchActor(who);
+      if (!remote || !remote.inbox) return 202;
+      const fi = actorInfo(remote, who);
+      luisteraars.voegToe(libSlug, {
+        actorUri: who, inbox: remote.inbox,
+        sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
+        name: fi.name, handle: fi.handle, icon: fi.icon,
+      });
+      const keys = getOrCreateKeys(libSlug);
+      const accept = {
+        '@context': AP_CONTEXT,
+        id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
+        type: 'Accept', actor: actorId(base, libSlug), object: act,
+      };
+      deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
+        .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
+      console.log('[AP] library follow from', who, '->', libSlug);
+      return 202;
+    }
+    // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
+    // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
+    // kon een Follow op andermans actor met dezelfde padstaart hier een volger
+    // opleveren.
+    const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
+    if (!who || !slug) return 400;
+    const remote = await fetchActor(who);
+    if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
+    const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
+    const fi = actorInfo(remote, who);   // cache display for the friends list (shaer-aa3)
+    // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
+    // follow is gated. A committed guardian's own Follow is auto-accepted
+    // (it needs no gate); anyone else is held pending for guardian approval.
+    // Free actors / normal sites have no guardians → fall through, unchanged.
+    const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
+    if (wardGuardians.length && !wardGuardians.includes(who)) {
+      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
+      Guardianship.follows.recordPending(slug, {
+        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
+        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
+      });
+      // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
+      // gated follow to its guardians for approval. A LOCAL guardian gets a
+      // push and reads /guardian directly; a REMOTE guardian gets an
+      // Offer(Follow) delivered so its instance stores a copy (same distributed
+      // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
+      const wardActor = actorId(base, slug);
+      const wardKeys = getOrCreateKeys(slug);
+      const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
+      // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
+      // every guardian. The ONLY admissible evidence is a request like this
+      // one going unanswered; recordRequest itself skips a declared absence.
+      for (const g of wardGuardians) {
+        try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
+      }
+      for (const g of wardGuardians) {
+        // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
+        // ignores the host (an /ap/users/x path on a remote host is someone
+        // else's actor), so also require our base + an existing local site.
+        const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
+        const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
+        if (isLocal) {
+          const L = pushLang(gslug);
+          // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van
+          // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de
+          // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie
+          // er vraagt, en om wie het gaat (shaer-p729).
+          pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folin_t'), body: i18nT(L, 'push.n_guard_folin_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
+        } else {
+          fetchActor(g).then((ga) => {
+            const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
+            if (!inbox) return;
+            const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
+            const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:decisive': beslissend2 };
+            deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
+          }).catch(() => {});
+        }
+      }
+      console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
+      return 202;
+    }
+    // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt
+    // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde
+    // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op
+    // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander
+    // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.
+    // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.
+    const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);
+    if (ownerGate && ownerGate.approve_followers) {
+      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
+      Guardianship.follows.recordPending(slug, {
+        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
+        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',
+      });
+      const L = pushLang(slug);
+      pushEvent(slug, {
+        type: 'follow',
+        title: i18nT(L, 'push.n_folreq_t'),
+        body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),
+        url: `${pushPrefix(slug)}/connect`,
+      });
+      console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');
+      return 202;
+    }
+    fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
+    try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
+    { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
+    const me = actorId(base, slug);
+    const keys = getOrCreateKeys(slug);
+    const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
+    deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
+    // Auto-backfill: send our recent posts as Create so the instance has our history
+    // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
+    // Mastodon dedupes notes per-instance, so re-filling an instance that already has
+    // a follower of ours is wasted work (and won't re-populate the new follower's
+    // timeline anyway). Deliver to the shared inbox (instance-level) when present.
+    // Sync insert+check (no await between) → no interleave race with concurrent Follows.
+    const instanceFilled = sharedInbox &&
+      db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
+        .get(slug, sharedInbox, who);
+    if (!instanceFilled) {
+      backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
+    }
+    console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
+    return 202;
+  }
+  // Een luisteraar die weggaat, hoort meteen weg te zijn.
+  if (type === 'Undo' && act.object && act.object.type === 'Follow') {
+    const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
+    const libSlug = libraryOwnerSlug(doel);
+    const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
+    if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
+      console.log('[AP] library unfollow from', wie, '->', libSlug);
+      return 202;
+    }
+  }
+
+  if (type === 'Undo' && act.object) {
+    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
+    const ot = act.object.type;
+    if (ot === 'Follow') {
+      const obj = act.object.object;
+      const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
+      if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
+      return 202;
+    }
+    if (ot === 'Like' || ot === 'Announce') {
+      const tgt = act.object.object;
+      const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
+      if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
+      return 202;
+    }
+    return 202;
+  }
+
+  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
+  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
+  // Our OWN activity is already stored via ap_outbox: don't store it twice.
+  // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
+  // machine". The old reading dropped every activity between two sites on one
+  // instance, so a note from a co-located guardian to its ward was accepted
+  // with a 202 and then quietly thrown away: no mention, no away, no help
+  // request. Neighbours are not us (Robins regel, 29-7: on this machine
+  // everything behaves as if every Klonkt were somewhere else).
+  const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
+
+  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
+  if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
+    const o = act.object;
+    // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
+    // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
+    // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
+    if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
+      const seg = postIdFromNoteUrl(o.inReplyTo, base);
+      if (seg && localPostExists(seg)) {
+        const rec = recordPollBallot(seg, actorUri, o.name);
+        if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
+      }
+    }
+    const tgt = findThreadTarget(o.inReplyTo, base);
+    if (tgt && actorUri && !isLocalActor) {
+      const ai = actorInfo(await resolveActor(actorUri), actorUri);
+      const html = HtmlSanitizerService.sanitize(o.content || '');
+      if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
+      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
+      console.log('[AP] reply', actorUri, '→', tgt.post_id);
+      // A reply is a post too: Berichten renders it the way de Krant renders a
+      // timeline row, so it needs the same media and the same quote/preview card.
+      {
+        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
+        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
+        const mj = mediaFromNote(o);
+        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
+        resolveCard(o).then((c) => {
+          if (!c) return;
+          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
+          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
+        }).catch(() => { /* best-effort */ });
+      }
+      {
+        // Private (followers/direct) replies push as a DM ping WITHOUT content
+        // (the push service should never carry private text, design decision);
+        // public replies carry a short snippet.
+        const ctx = pushPostCtx(tgt.post_id);
+        const vis = noteVisibility(o);
+        const priv = vis === 'direct' || vis === 'followers';
+        if (ctx) {
+          const L = pushLang(ctx.site);
+          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
+          if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
+          else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
+        }
+      }
+      return 202;
+    }
+    // Home timeline (client): a top-level post from an account we follow.
+    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
+      let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
+      if (subs.length) {
+        const ai = actorInfo(await resolveActor(actorUri), actorUri);
+        const { html, atts: _atts, url: _url } = timelineFields(o);
+        const media = JSON.stringify(_atts);
+        const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
+        // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
+        // incoming posts to the fediverse — that flooded followers. Boosting to the
+        // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
+        // the timeline).
+        for (const s of subs) {
+          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));
+          // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
+          if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
+          // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
+          { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
+          storeAuthorEmoji(o.id, s.slug, ai);   // custom-emoji display name for the byline
+
+          // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
+          { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
+          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
+        }
+        // FEP-044f embedded quote card: resolve the quoted post out of band so
+        // the inbox response is not blocked on a remote fetch. Best-effort.
+        if (quoteHrefOf(o)) {
+          const slugs = subs.map((s) => s.slug);
+          resolveQuote(o).then((qj) => {
+            if (!qj) return;
+            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
+          }).catch(() => { /* best-effort */ });
+        } else {
+          // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
+          // thumbnail-only. Also out of band, and stored for everyone; the gate
+          // that decides who may SEE it is applied at serve time (§5.3-style
+          // gated feature, see the inbox read).
+          const slugs = subs.map((s) => s.slug);
+          resolveExternalEmbed(o.content).then((ej) => {
+            if (!ej) return;
+            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }
+          }).catch(() => { /* best-effort */ });
+        }
+        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
+      }
+    }
+    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
+    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
+    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
+    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
+    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
+    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
+    // moet iemand zijn die je zelf bent gaan volgen.
+    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
+      rememberNoteUri(o.id);
+    }
+    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
+    // above): store a mention notification for each of our actors named in the Mention tags.
+    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
+    // someone else's actor, not ours.
+    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
+    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
+    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
+    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
+    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
+    if (actorUri && !isLocalActor) {
+      const mark = Guardianship.help.parseMarker(o);
+      if (mark) {
+        const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
+        Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
+        wakeGuardian(slug);   // een mede-guardian pakte iets op: het paneel hoort het meteen
+        console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
+      }
+    }
+    if (actorUri && !isLocalActor && o.id) {
+      const slugs = localMentionSlugs(o.tag, base);
+      if (slugs.length) {
+        const ai = actorInfo(await resolveActor(actorUri), actorUri);
+        const html = HtmlSanitizerService.sanitize(o.content || '');
+        // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
+        // flag is stored so the Guardian PWA's message centre can list it.
+        const help = Guardianship.isHelpRequest(o);
+        const wave = Guardianship.isWave(o);
+        const hasG = Guardianship.objectHasGuardians(o);   // §2.2 hint, register-only
+        // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
+        // same direct note the mention below stores (so the kid also reads it
+        // as an ordinary message). Recorded only from an actual guardian of
+        // the addressed ward, and only with an end: an absence without an end
+        // is logged and dropped, never guessed.
+        if (Guardianship.availability.isAway(o)) {
+          const until = Guardianship.availability.parseEndTime(o.endTime);
+          for (const slug of slugs) {
+            const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
+            if (!isG) continue;
+            if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
+            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);
+              wakeGuardian(slug);   // het kind vroeg om een poort
+              console.log('[AP] gate request', req.feature, actorUri, '→', slug);
+            }
+          }
+        }
+        for (const slug of slugs) {
+          try {
+            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
+                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
+              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
+                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
+            if (r.changes) {
+              // The quote / link-preview card resolves out of band (a remote
+              // fetch), exactly as it does for a timeline post, so the inbox
+              // answer is never blocked on it.
+              resolveCard(o).then((c) => {
+                if (!c) return;
+                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
+                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
+              }).catch(() => { /* best-effort */ });
+              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
+              const vis = noteVisibility(o);
+              const priv = vis === 'direct' || vis === 'followers';
+              const L = pushLang(slug);
+              const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
+              // Same privacy rule as replies: private mentions push without content.
+              // A help request pushes as its own alert type, aimed at the
+              // Guardian PWA's message centre.
+              if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
+              else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
+              else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
+            }
+          } catch { /* ignore */ }
+        }
+      }
+    }
+    return 202;
+  }
+  // A remote post we cached was edited upstream → refresh our cached copy. This is the
+  // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
+  // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
+  // edit A's note (the signature gate guarantees claimedActor == the verified signer).
+  if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
+    const o = act.object;
+    if (o.id && claimedActor) {
+      const html = HtmlSanitizerService.sanitize(o.content || '');
+      const media = mediaFromNote(o);
+      try {
+        // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
+        // rename keeps the same AP id but changes the human url, so without this the cached
+        // post would keep linking to the old, now-dead URL.
+        const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
+          .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
+        if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
+        // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
+        // site keeps its own `voted` state while the counts/closed update to the new totals.
+        const poll = parsePoll(o);
+        if (poll) {
+          const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
+          const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
+          for (const rw of rows) {
+            let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
+            upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
+          }
+        }
+      } catch { /* ignore */ }
+      // If this note is a cached fediverse reply on one of our posts, refresh its text too.
+      try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
+    }
+    return 202;
+  }
+  if (type === 'Like' || type === 'Announce') {
+    const tgt = act.object;
+    const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
+    const pid = postIdFromNoteUrl(objUrl, base);
+    if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
+      // A boost/like of a non-public post is dropped, not stored: nobody
+      // outside the audience should even hold it (shaer-tqc hardening).
+      const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
+      if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
+        console.log('[AP] dropped', type, 'on non-public post', pid);
+        return;
+      }
+      const ai = actorInfo(await resolveActor(actorUri), actorUri);
+      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
+      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
+      {
+        const ctx = pushPostCtx(pid);
+        if (ctx) {
+          const L = pushLang(ctx.site);
+          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
+          if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
+          else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
+        }
+      }
+    } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
+      // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
+      // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
+      // re-announcing an incoming Announce would cascade boosts across the network).
+      let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
+      if (subs.length) {
+        const bn = await fetchNoteAP(objUrl);
+        if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
+          const origUri = actorUriOf(bn.attributedTo);
+          // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
+          // author is blocked — otherwise a block is bypassed via someone else's boost.
+          if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
+          const oai = actorInfo(await resolveActor(origUri), origUri);
+          const html = HtmlSanitizerService.sanitize(bn.content || '');
+          const media = mediaFromNote(bn);
+          const booster = actorInfo(await resolveActor(actorUri), actorUri);
+          for (const s of subs) {
+            // published = now → the boost shows as fresh activity at the top (Mastodon shows
+            // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
+            // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
+            let inserted = false;
+            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }
+            if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
+            storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
+            // A boost carries the same renderable tags as a Create: capture the
+            // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
+            // 044f) so boosted posts render like any other, not as raw shortcodes.
+            { const ej = extractEmojiTags(bn.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, bn.id, s.slug); } catch { /* ignore */ } } }
+            { const lj = extractLinkJson(bn); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, bn.id, s.slug); } catch { /* ignore */ } } }
+          }
+          // FEP-044f: resolve the embedded quote card for a boosted post too
+          // (out of band, best-effort, so it does not block the inbox response).
+          if (quoteHrefOf(bn)) {
+            const slugs = subs.map((s) => s.slug);
+            resolveQuote(bn).then((qj) => {
+              if (!qj) return;
+              for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, bn.id, sl); } catch { /* ignore */ } }
+            }).catch(() => { /* best-effort */ });
+          }
+          console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
+        }
+      }
+    }
+    return 202;
+  }
+  if (type === 'Delete') {
+    // A remote note was deleted upstream → drop it from replies AND the timeline.
+    // Scope to the SIGNING actor so actor B can't delete actor A's content (the
+    // signature gate guarantees claimedActor == the verified signer here).
+    const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
+    if (oid && claimedActor) {
+      try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
+      try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
+      // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
+      // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
+      // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
+      // to A's posts).
+      try {
+        let sameHost = false;
+        try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
+        if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
+      } catch { /* ignore */ }
+    }
+    return 202;
+  }
+  // Accept/Reject of a Follow WE sent (client side).
+  if (type === 'Accept' && act.object) {
+    const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
+    let raak = 0;
+    if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }
+    // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de
+    // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een
+    // EIGEN id voor, in ONZE namespace:
+    //
+    //   wij stuurden   .../ap/users/dev#follow-1786161977286-bb2de32f
+    //   Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...
+    //
+    // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op
+    // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op
+    // die pas opviel toen er nooit iets binnenkwam.
+    //
+    // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is
+    // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een
+    // rij die nog op pending staat wordt geraakt, dus dit kan niets anders
+    // openzetten dan een follow die wij zelf hebben verstuurd.
+    //
+    // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de
+    // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten
+    // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is
+    // onze eigen actor-URI.
+    let mij = slugParam;
+    if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);
+    if (!raak && mij && actorUri) {
+      try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }
+    }
+    // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te
+    // zien te zijn in plaats van als succes voorbij te komen.
+    console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');
+    // The moment a friendship exists is the moment the history comes along
+    // (Robins besluit, 30-7): delivery cannot reach into the past, so the
+    // fresh follower pulls the outbox, signed, and the other side now serves
+    // the friends-only posts too.
+    if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
+    return 202;
+  }
+  if (type === 'Reject' && act.object) {
+    const who = actorUri;
+    if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
+    return 202;
+  }
+
+  // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil
+  // tussen een soort die we bewust overslaan en een die we niet kennen -- en
+  // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt
+  // waar deze inbox alleen Note, Article en Question aanneemt.
+  const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);
+  console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',
+    'from', ip, 'by', claimedActor || '?', '(ignored)');
+  return 202;
+}
+
