Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
+++ src/services/ActivityPubService.js	(revision 76bd53c87f8d2d93af80fc4939f7e485da07ecbc)
@@ -1159,24 +1159,22 @@
   const gaten = (n) => Array.from({ length: n }, () => '?').join(',');
   const posts = postIds.length ? db.prepare(
-    // fan_only en ap_visibility MOETEN mee. buildNote adresseert hierop, en
-    // zonder deze twee kolommen is post.fan_only altijd undefined: elke
-    // fan-only post ging dan de outbox uit met to: as:Public, terwijl hij
-    // alleen aan vrienden geserveerd wordt. Een volger kreeg dus een
-    // vrienden-post met een publiek etiket erop, en die mag hij dan publiek
-    // boosten. Gevonden tijdens de FEP-1580 end-to-end test (shaer-fuyo).
-    // EN paid + excerpt, om exact dezelfde reden (Barts melding, 15-8). Zonder
-    // `paid` is post.paid hier `undefined`, dan slaat buildNote zijn redactie
-    // over en gaat de VOLLEDIGE tekst van een betaalde post de outbox uit. Zo
-    // kwam een post via een hub-actor gewoon te lezen. `excerpt` moet mee omdat
-    // de teaser daaruit komt; zonder dat veld valt hij terug op de eerste
-    // alinea van precies de tekst die verborgen hoort te blijven.
+    // ALLE KOLOMMEN, zoals /ap/notes/:id ook doet. Hier stond een lijst, en
+    // die faalde stil: een vergeten kolom is `undefined` en geen fout, en
+    // buildNote besluit dan zonder dat veld. Het ging vier keer mis voordat
+    // deze regel er stond --
+    //   fan_only/ap_visibility: een vriendenpost ging de deur uit als publiek
+    //     (shaer-fuyo);
+    //   paid/excerpt: de VOLLEDIGE tekst van een betaalde post stond in de
+    //     outbox (Barts melding, 15-8);
+    //   tags, poll_json, quote_uri/quote_actor, cover_alt, language: geen
+    //     hashtags, een peiling als gewone post, een citaat zonder citaat
+    //     (Robin, 30-9 -- de hub vond de tags van soundfabrics niet, want die
+    //     staan niet in de tekst en kwamen alleen via de outbox binnen).
     //
-    // Dit is een KOLOMMENLIJST, en die faalt stil: een vergeten kolom is
-    // `undefined` en niet een fout. Wie hier een veld toevoegt waar buildNote
-    // op beslist, moet het HIER ook toevoegen.
-    `SELECT id, slug, title, excerpt, content, cover_image_url, cover_video_url, nsfw, content_warning,
-            c2s_attachments, quote_json, embed_json, published_at, created_at,
-            fan_only, ap_visibility, paid, paid_min_cents
-       FROM posts WHERE id IN (${gaten(postIds.length)})`).all(...postIds) : [];
+    // De outbox en de losse Note horen HETZELFDE object te zijn; wie een post
+    // op twee manieren ophaalt, hoort geen twee verschillende berichten te
+    // zien. test/outbox-gelijk-aan-note.test.js legt dat vast, voor elk veld,
+    // ook voor velden die er later bijkomen.
+    `SELECT * FROM posts WHERE id IN (${gaten(postIds.length)})`).all(...postIds) : [];
   const tracks = trackIds.length ? db.prepare(
     `SELECT ${TRACK_KOLOMMEN}
Index: test/outbox-gelijk-aan-note.test.js
===================================================================
--- test/outbox-gelijk-aan-note.test.js	(revision 76bd53c87f8d2d93af80fc4939f7e485da07ecbc)
+++ test/outbox-gelijk-aan-note.test.js	(revision 76bd53c87f8d2d93af80fc4939f7e485da07ecbc)
@@ -0,0 +1,78 @@
+// De outbox en de losse Note zijn HETZELFDE object (Robin, 30-9).
+//
+// Aanleiding: de hub vond de hashtags van soundfabrics niet. Haal je een post
+// los op (/ap/notes/:id) dan draagt hij `#kawaii`; lees je hem uit de outbox,
+// dan niet. Oorzaak: outboxSlice haalde de posts op met een vaste lijst
+// kolommen, en die lijst faalt stil -- een vergeten kolom is `undefined`,
+// buildNote beslist zonder, en er komt geen foutmelding. `tags` ontbrak, maar
+// ook poll_json, quote_uri, quote_actor, cover_alt en language. Het was de
+// vierde keer: eerder ging het zo mis met fan_only en met paid.
+//
+// Deze toets vergelijkt daarom niet een paar velden maar het HELE object. Een
+// kolom die er later bijkomt en in de outbox niet meekomt, valt hier meteen op.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://ons.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+const BASE = 'https://ons.test';
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+  .run('s1', 'band', 'De Band', 'u1');
+const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+
+// Een post die ELK veld gebruikt waar het misging -- en de hashtags staan
+// bewust niet in de tekst, precies zoals bij soundfabrics.
+db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at,
+            tags, poll_json, quote_uri, quote_actor, cover_image_url, cover_alt, language)
+            VALUES (?,?,?,?,?,?,'published',?,?,?,?,?,?,?,?)`)
+  .run('p1', 's1', 'u1', 'alles', 'Alles', '<p>geen hashtag in deze zin</p>', '2026-09-30T10:00:00Z',
+    JSON.stringify(['kawaii', 'leuk']),
+    JSON.stringify({ options: [{ name: 'ja' }, { name: 'nee' }], endTime: '2099-01-01T00:00:00Z' }),
+    'https://elders.test/notes/1', 'https://elders.test/users/iemand',
+    '/media/hoes.jpg', 'Een oranje hoes', 'nl');
+// Een gewone post met alleen een omslag: bij een citaat of peiling laat
+// buildNote de omslag bewust weg, dus de alt-tekst toetsen we los.
+db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at,
+            cover_image_url, cover_alt) VALUES (?,?,?,?,?,?,'published',?,?,?)`)
+  .run('p2', 's1', 'u1', 'hoes', 'Hoes', '<p>een plaatje</p>', '2026-09-29T10:00:00Z',
+    '/media/hoes.jpg', 'Een oranje hoes');
+const los = (id) => AP.buildNote(BASE, site, db.prepare('SELECT * FROM posts WHERE id = ?').get(id));
+
+const uitOutbox = (apId) => {
+  const { posts, tracks } = AP.outboxSlice('s1', {});
+  const ob = AP.buildOutbox(BASE, site, posts, tracks);
+  const create = (ob.orderedItems || []).find((a) => a.object && a.object.id === apId);
+  return create && create.object;
+};
+
+test('de outbox levert hetzelfde object als de losse Note', () => {
+  // Zoals /ap/notes/:id hem ophaalt: SELECT *. Voor elke post, niet een.
+  for (const id of ['p1', 'p2']) {
+    const note = los(id);
+    const uitDeOutbox = uitOutbox(note.id);
+    assert.ok(uitDeOutbox, `${id} staat in de outbox`);
+    assert.deepEqual(uitDeOutbox, note, 'wie een post op twee manieren ophaalt, hoort een bericht te zien');
+  }
+});
+
+test('en de velden waar het misging zitten er echt in', () => {
+  const o = uitOutbox(los('p1').id);
+  const tags = (o.tag || []).filter((t) => t.type === 'Hashtag').map((t) => t.name).sort();
+  assert.deepEqual(tags, ['#kawaii', '#leuk'], 'de hashtags, ook al staan ze niet in de tekst');
+  assert.equal(o.type, 'Question', 'een peiling blijft een peiling');
+  assert.ok(JSON.stringify(o).includes('https://elders.test/notes/1'), 'het citaat reist mee');
+  // Zonder audio is de omslag een BIJLAGE (met alt als `name`); `image` krijgt
+  // hij alleen als de bijlagen onderdrukt worden voor een speler.
+  const o2 = uitOutbox(los('p2').id);
+  const hoes = (o2.attachment || []).find((a) => String(a.url || '').endsWith('/media/hoes.jpg'));
+  assert.equal(hoes && hoes.name, 'Een oranje hoes', 'de omslag houdt zijn alt-tekst');
+  assert.ok(o.contentMap && o.contentMap.nl, 'de taal staat erbij');
+});
