Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 0ad37a0db1ff72679d2aa6167ca6c1905a40c720)
+++ 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.
Index: test/c2s-direct.test.js
===================================================================
--- test/c2s-direct.test.js	(revision 0ad37a0db1ff72679d2aa6167ca6c1905a40c720)
+++ test/c2s-direct.test.js	(revision 407353ef08214dd2250ec198e4d534584abfd4ed)
@@ -77,2 +77,38 @@
   assert.equal(like.status, 403);
 });
+
+// Een bijlage KAN het hele bericht zijn (shaer-6lcc).
+//
+// Twee lagen waren het oneens over wat een bericht is: de inname liet een
+// note met alleen een bijlage door, en deliverDirectNote weigerde hem een
+// laag lager op `!text.trim()`. Wie een foto op een gesprek liet vallen en
+// niets typte kreeg daardoor "502 direct_failed".
+test('een direct bericht met alleen een bijlage telt als inhoud', async () => {
+  const { directNoteHasContent } = await import('../src/services/guardianship/delivery.js');
+
+  assert.equal(directNoteHasContent({ text: 'hallo' }), true);
+  assert.equal(directNoteHasContent({ html: '<p>hallo</p>' }), true);
+  assert.equal(directNoteHasContent({
+    text: '',
+    attachments: [{ url: '/media/reply-media/x.png', mediaType: 'image/png' }],
+  }), true, 'een foto zonder woorden is een bericht');
+  assert.equal(directNoteHasContent({
+    text: '   \n  ',
+    attachments: [{ url: '/media/reply-media/x.png', mediaType: 'image/png' }],
+  }), true, 'witruimte is geen tekst, maar de bijlage draagt hem');
+
+  // En leeg blijft leeg: dit mag de deur niet uit.
+  assert.equal(directNoteHasContent({}), false);
+  assert.equal(directNoteHasContent({ text: '   ', html: '', attachments: [] }), false);
+  assert.equal(directNoteHasContent(), false);
+});
+
+// De inname en de bezorging horen dezelfde grens te trekken. Loopt er eentje
+// weg, dan is dat precies de 502 hierboven, en die valt buiten de tests om.
+test('inname en bezorging zijn het eens over een leeg bericht', async () => {
+  const { directNoteHasContent } = await import('../src/services/guardianship/delivery.js');
+  const src = (await import('fs')).readFileSync('src/services/ap-c2s.js', 'utf8');
+  assert.ok(src.includes("error: 'empty_note'"), 'de inname heeft nog een lege-bericht-controle');
+  // De inname laat een bijlage-only note door; de bezorging hoort dat ook te doen.
+  assert.equal(directNoteHasContent({ text: '', attachments: [{ url: '/media/x.png' }] }), true);
+});
