Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 8a474ab1b2ce34c493e6e674100b989019d38cfe)
+++ src/services/guardianship/delivery.js	(revision 407353ef08214dd2250ec198e4d534584abfd4ed)
@@ -35,4 +35,24 @@
 }
 
+/**
+ * Heeft dit directe bericht iets te zeggen? Puur, zodat de regel te toetsen is
+ * zonder database, netwerk of scherm.
+ *
+ * EEN FOTO KAN HET HELE BERICHT ZIJN. Hier stond alleen `!text.trim()`, en dat
+ * betekende: een direct bericht zonder woorden gaf null terug, waar de inname
+ * "502 direct_failed" van maakte. Precies wat je kreeg als je een foto op een
+ * gesprek liet vallen en niets typte -- de inname liet een bijlage-only note
+ * door, en deze laag eronder weigerde hem alsnog. Twee lagen die het niet eens
+ * waren over wat een bericht is.
+ *
+ * Een leeg bericht blijft geweigerd: geen tekst, geen opmaak en geen bijlage is
+ * niets, en dat hoort niet de deur uit te gaan.
+ */
+export function directNoteHasContent({ text, html, attachments } = {}) {
+  if (String(text || '').trim()) return true;
+  if (String(html || '').trim()) return true;
+  return Array.isArray(attachments) && attachments.length > 0;
+}
+
 // A direct note: a NEW conversation (or a direct reply) addressed to specific
 // actors only. Stored in ap_outbox with visibility 'direct' + the recipient
@@ -53,5 +73,6 @@
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
   const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
-  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
+  if (!base || !site || !site.slug || !list.length) return null;
+  if (!directNoteHasContent({ text, html, attachments })) return null;
   const me = actorId(base, site.slug);
   // Resolve every recipient for a mention anchor + a delivery inbox.
@@ -108,5 +129,5 @@
   const richClean = html ? deps.sanitizeHtml(String(html)) : '';
   const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
-  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
+  const body = escHtml(String(text || '').trim()).replace(/\r?\n/g, '<br>');
   // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
   // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
