Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision abd2093ba9f4e77e92872fc4986808981847fc53)
+++ src/services/ap-core.js	(revision a982e7eea932c92389eb43741ea6f66181756dd3)
@@ -57,20 +57,35 @@
     // zou een verhuizing kunnen laten mislukken.
     sameAs: { '@id': 'schema:sameAs', '@type': '@id' },
-    // ── De ENIGE term die we uit Funkwhale's eigen vocabulaire overnemen ──
+    // ── Wat we uit Funkwhale's vocabulaire overnemen, en waarom ──
     //
-    // Waarom deze wel en track/ArtistCredit niet (shaer-0nh): die twee vragen
-    // ENTITEITEN waar wij tekst hebben -- een artiest en een album zijn bij ons
-    // kolommen, en er een id voor verzinnen zou beloven wat we niet kunnen
-    // waarmaken. Een Library is iets anders: het is precies wat we AL hebben,
-    // een verzameling van onze eigen open tracks met een echte telling.
+    // LIBRARY. Gemeten op 13-8: open.audio had onze vier tracks binnengehaald
+    // via hun AP-id, met een artist_credit dat het zelf uit onze attributedTo
+    // afleidde -- maar uploads LEEG en is_playable false. Bij Funkwhale hangt
+    // een upload aan een library; zonder die bak blijft een track een naam
+    // zonder geluid.
     //
-    // En hij doet iets. Gemeten op 13-8: open.audio heeft onze vier tracks
-    // binnengehaald via hun AP-id, met artist_credit dat het zelf uit onze
-    // attributedTo afleidde -- maar uploads is LEEG en is_playable false. Bij
-    // Funkwhale hangt een upload aan een library; zonder library is er geen bak
-    // om het bestand in te hangen, en blijft een track een naam zonder geluid.
+    // TRACK. Hier stond dat we deze NIET namen, met als reden: hij vraagt een
+    // entiteit waar wij tekst hebben, en er een id voor verzinnen belooft wat
+    // we niet waarmaken. Die redenering klopte half, en de Emissary-meting van
+    // 16-8 (shaer-3f8a) laat zien welke helft. Een TRACK heeft bij ons wel
+    // degelijk een eigen identiteit -- het is een rij met een titel en een
+    // plaats in een uitgave. Wat wij niet hebben is een ALBUM als entiteit, en
+    // dat is een andere vraag (shaer-k37k). Emissary stuurt precies die kleine
+    // vorm: type, id, name, position. Wij ook, en album blijft eruit tot het
+    // een echt object is -- een verzonnen album-URI is nu juist wel de belofte
+    // die we niet kunnen waarmaken.
+    //
+    // Twee onafhankelijke implementaties zenden dit nu, en het is de kant waar
+    // FEP-be68 heen beweegt. ArtistCredit blijft eruit: dat is nog steeds een
+    // entiteit die wij niet hebben.
+    //
+    // De vorm van de termen is letterlijk die van hun contexts.py (regel
+    // 293-306), zodat een lezer die hun context laadt en een lezer die de onze
+    // leest op dezelfde IRI's uitkomen.
     fw: 'https://funkwhale.audio/ns#',
     Library: 'fw:Library',
     library: { '@id': 'fw:library', '@type': '@id' },
+    Track: 'fw:Track',
+    track: { '@id': 'fw:track', '@type': '@id' },
     position: 'schema:position',
     bitrate: 'schema:bitrate',
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision abd2093ba9f4e77e92872fc4986808981847fc53)
+++ src/services/music/index.js	(revision a982e7eea932c92389eb43741ea6f66181756dd3)
@@ -182,4 +182,23 @@
   // van te maken.
   if (post) a.context = noteId(base, post.id);
+  // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
+  // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
+  // ervan als eerste wat hem opviel aan onze objecten.
+  //
+  // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
+  // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
+  // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
+  // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
+  // een geldige IRI en wijst naar hetzelfde document.
+  //
+  // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
+  // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
+  // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
+  a.track = {
+    type: 'Track',
+    id: `${a.id}#track`,
+    name: a.name,
+    ...(Number(r.position) ? { position: Number(r.position) } : {}),
+  };
   if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
   if (r.created_at) a.published = new Date(r.created_at).toISOString();
Index: test/ap-fw-track.test.js
===================================================================
--- test/ap-fw-track.test.js	(revision a982e7eea932c92389eb43741ea6f66181756dd3)
+++ test/ap-fw-track.test.js	(revision a982e7eea932c92389eb43741ea6f66181756dd3)
@@ -0,0 +1,85 @@
+// `fw:track` op onze Audio-objecten (shaer-3f8a, spoor B stap 1).
+//
+// Twee onafhankelijke implementaties lezen dit veld: Funkwhale (verplicht in
+// UploadSerializer) en Emissary 0.9.0, gemeten op bandwagon.fm 16-8. Emissary
+// stuurt de kleine vorm -- type, id, name, album, position -- en die nemen we
+// over, zonder album zolang dat bij ons geen object is (shaer-k37k).
+//
+// Wat deze test vastlegt is vooral het ID: het nummer krijgt een EIGEN
+// identiteit met #track, niet die van het bestand. Emissary hergebruikt daar
+// het object-id, en dan zijn de Audio en de Track in JSON-LD een knoop met twee
+// typen. Wie dat 'opruimt' naar het kortere id haalt dat verschil weg, en dan
+// staat er dat een mp3 een muziekstuk IS.
+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();
+
+db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)')
+  .run('m1', 's1', 'een.mp3', 'a/een.mp3', 'audio/mpeg', 2480880);
+db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open, position) VALUES (?,?,?,?,?,?,?,?)')
+  .run('t1', 's1', 'Het nummer', 'De Band', 103, 'm1', 1, 3);
+// Eentje zonder positie: die mag het veld niet verzinnen.
+db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)')
+  .run('m2', 's1', 'twee.mp3', 'a/twee.mp3', 'audio/mpeg', 100);
+db.prepare('INSERT INTO audio_tracks (id, site_id, title, media_id, fedi_open) VALUES (?,?,?,?,?)')
+  .run('t2', 's1', 'Zonder plek', 'm2', 1);
+
+const audio = (id) => AP.buildTrackAudio(BASE, site, AP.openTrack('s1', id), { hostPosts: null });
+
+test('elke Audio draagt een track met de kleine Emissary-vorm', () => {
+  const a = audio('t1');
+  assert.ok(a.track, 'geen track op het object');
+  assert.equal(a.track.type, 'Track');
+  assert.equal(a.track.name, 'Het nummer');
+  assert.equal(a.track.position, 3);
+});
+
+test('de track heeft een EIGEN id, niet dat van het bestand', () => {
+  const a = audio('t1');
+  assert.equal(a.track.id, `${a.id}#track`);
+  assert.notEqual(a.track.id, a.id, 'een bestand is geen werk');
+});
+
+test('geen album zolang het bij ons geen object is', () => {
+  // Een tekstkolom als URI meesturen is een adres beloven dat niet bestaat.
+  assert.equal(audio('t1').track.album, undefined);
+});
+
+test('geen verzonnen positie', () => {
+  assert.equal(audio('t2').track.position, undefined);
+  assert.equal(audio('t2').track.name, 'Zonder plek');
+});
+
+test('de termen staan in de context, anders valt het veld weg', async () => {
+  const core = await import('../src/services/ap-core.js');
+  const term = core.AP_CONTEXT.find((x) => x && typeof x === 'object' && x.track);
+  assert.ok(term, 'track staat in de context');
+  // Letterlijk de vorm uit hun contexts.py (regel 293-306), zodat een lezer die
+  // hun context laadt op dezelfde IRI's uitkomt als een lezer die de onze leest.
+  assert.deepEqual(term.track, { '@id': 'fw:track', '@type': '@id' });
+  assert.equal(term.Track, 'fw:Track');
+});
+
+test('de track komt overal mee, niet alleen op de losse ophaal', () => {
+  // Bibliotheek, trackcollectie en losse ophaal bouwen alle drie via
+  // buildTrackAudio -- dit is de test die dat vasthoudt als iemand er een
+  // eigen bouwertje naast zet.
+  const lib = AP.buildLibrary(BASE, site, AP.siteOpenTracks('s1'));
+  for (const a of lib.items) assert.equal(a.track.type, 'Track', `${a.name} zonder track`);
+  const col = AP.buildTrackCollection(BASE, site, AP.siteOpenTracks('s1'));
+  for (const a of col.orderedItems) assert.equal(a.track.type, 'Track', `${a.name} zonder track`);
+});
