Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision aa521c34b7f04012f799c30105288fb176538c25)
+++ src/config/database.js	(revision 8151340aee19dd0280b5185a8c3e9c56dc279653)
@@ -21,4 +21,31 @@
 db.pragma('busy_timeout = 5000');   // wait up to 5s for a lock instead of failing immediately
 db.pragma('synchronous = NORMAL');  // safe with WAL (no torn writes); fewer fsyncs = faster writes
+
+/**
+ * Tijdstempels in EEN spelling (shaer-a937).
+ *
+ * Deze database draagt twee vormen door elkaar: '2026-08-06 09:02:01' van
+ * CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString(). SQLite
+ * vergelijkt ze als TEKST, en op positie 10 staat een 'T' (0x54) tegenover een
+ * spatie (0x20) -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij
+ * ook is. Een antwoord van 02:08 kwam zo boven een like van 09:02 te staan.
+ *
+ * `NU_ISO` is wat je SCHRIJFT, `isoSql()` is waarmee je VERGELIJKT of SORTEERT.
+ * De twee horen bij elkaar: het eerste zorgt dat er niets nieuws bijkomt, het
+ * tweede dat wat er al staat toch goed op volgorde komt.
+ *
+ * isoSql valt met COALESCE terug op de RAUWE waarde: strftime geeft NULL op iets
+ * dat het niet als tijd herkent, en zonder die terugval zou zo'n rij uit de
+ * sortering vallen -- of erger, als hij ook geSELECTeerd wordt (de cursor in de
+ * gesprekslezing) zou de client een lege stempel terugkrijgen en zijn plek
+ * kwijtraken. Waar zo'n onleesbare waarde dan LANDT is onbepaald: hij wordt als
+ * tekst vergeleken en 'geen datum' staat nu eenmaal boven '2026-...'. Dat is de
+ * juiste ruil -- data die je niet begrijpt bewaar je, je gooit hem niet weg.
+ *
+ * Ze staan HIER en niet in een dienst omdat ze over de opslag gaan: elke plek
+ * die sorteert importeert `db` toch al uit dit bestand.
+ */
+export const NU_ISO = "strftime('%Y-%m-%dT%H:%M:%SZ','now')";
+export const isoSql = (expr) => `COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', ${expr}), ${expr})`;
 
 export function initializeDatabase() {
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision aa521c34b7f04012f799c30105288fb176538c25)
+++ src/services/ArchiveExportService.js	(revision 8151340aee19dd0280b5185a8c3e9c56dc279653)
@@ -18,5 +18,5 @@
 import path from 'path';
 import crypto from 'crypto';
-import db from '../config/database.js';
+import db, { isoSql } from '../config/database.js';
 import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js';
 
@@ -494,5 +494,5 @@
   // is de volgorde van twee posts op dezelfde seconde niet bepaald.
   const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
-                             ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
+                             ORDER BY ${isoSql('COALESCE(published_at, created_at)')} ASC, id ASC`).all(site.id);
 
   for (const post of posts) {
@@ -539,5 +539,5 @@
     try {
       replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
-                             ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
+                             ORDER BY ${isoSql('COALESCE(published, created_at)')} ASC, id ASC`).all(post.id);
     } catch { /* tabel kan ontbreken op een heel oude database */ }
     if (replies.length) {
Index: src/services/ap-cirkel.js
===================================================================
--- src/services/ap-cirkel.js	(revision aa521c34b7f04012f799c30105288fb176538c25)
+++ src/services/ap-cirkel.js	(revision 8151340aee19dd0280b5185a8c3e9c56dc279653)
@@ -7,5 +7,5 @@
  * een werktuig uit de dienstlaag: alleen db.
  */
-import db from '../config/database.js';
+import db, { isoSql } from '../config/database.js';
 
 // ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
@@ -29,5 +29,5 @@
       LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
       WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
-      ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
+      ORDER BY ${isoSql('COALESCE(t.published, t.created_at)')} DESC, t.rowid DESC
       LIMIT ? OFFSET ?`);
     return _cirkelPosts.all(slug, limit || 60, offset || 0);
Index: src/services/ap-timeline.js
===================================================================
--- src/services/ap-timeline.js	(revision aa521c34b7f04012f799c30105288fb176538c25)
+++ src/services/ap-timeline.js	(revision 8151340aee19dd0280b5185a8c3e9c56dc279653)
@@ -13,5 +13,10 @@
  * wireTimeline, hetzelfde injectiepatroon als guardianship en ap-c2s.
  */
-import db from '../config/database.js';
+import db, { isoSql } from '../config/database.js';
+
+// De helper woont sinds shaer-a937 in config/database.js: elke plek die
+// sorteert had hem nodig, en twee kopieen van dezelfde regel lopen uit elkaar.
+// Bovenaan, want de eerste statements hieronder gebruiken hem al.
+const STEMPEL = isoSql;
 
 // Het ene werktuig uit de dienstlaag. ActivityPubService vult het onderaan
@@ -26,5 +31,5 @@
   if (!_insTl) {
     _insTl = db.prepare('INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, nsfw, cw, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
-    _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ? OFFSET ?');
+    _listTl = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? ORDER BY ${STEMPEL('COALESCE(published, created_at)')} DESC LIMIT ? OFFSET ?`);
     _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
   }
@@ -119,5 +124,5 @@
       JOIN sites s ON s.id = p.site_id
       WHERE s.slug = ? AND i.kind = 'reply'
-      ORDER BY COALESCE(i.published, i.created_at) DESC LIMIT ?`).all(slug, limit || 60);
+      ORDER BY ${STEMPEL('COALESCE(i.published, i.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
   } catch { return []; }
 }
@@ -242,5 +247,4 @@
  * een leeftijd die twee uur verschilde per vorm.
  */
-const STEMPEL = (rauw) => `COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', ${rauw}), ${rauw})`;
 
 const CONVERSATION_UNION = `
@@ -425,5 +429,5 @@
       WHERE m.slug = ?
         AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
-      ORDER BY COALESCE(m.published, m.created_at) DESC LIMIT ?`).all(slug, limit || 60);
+      ORDER BY ${STEMPEL('COALESCE(m.published, m.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
   } catch { return []; }
 }
Index: test/timestamp-ordering.test.js
===================================================================
--- test/timestamp-ordering.test.js	(revision 8151340aee19dd0280b5185a8c3e9c56dc279653)
+++ test/timestamp-ordering.test.js	(revision 8151340aee19dd0280b5185a8c3e9c56dc279653)
@@ -0,0 +1,80 @@
+// Gemengde tijdstempels sorteren chronologisch (shaer-a937).
+//
+// Deze database draagt twee spellingen: '2026-08-06 09:02:01' van
+// CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString(). SQLite
+// vergelijkt TEKST, en op positie 10 staat een 'T' (0x54) tegenover een spatie
+// (0x20) -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij ook
+// is. Gemeten op de live database van sound-fabrics: een antwoord van 02:08
+// stond boven likes van 09:02 diezelfde dag, zichtbaar onder elke post met
+// reacties EN likes.
+//
+// De toets zet de twee vormen bewust door elkaar met de ISO-rij VROEGER op de
+// dag: precies de stand waarin een ongenormaliseerde sortering omvalt.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'robin', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
+db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+            VALUES ('p1','s1','u1','post','Post','<p>x</p>','published','2026-08-01T00:00:00Z')`).run();
+
+// SQL-notatie om 09:02, ISO om 02:08 -- dezelfde dag. Chronologisch hoort de
+// SQL-rij bovenaan; op tekst gesorteerd wint de ISO-rij.
+const LAAT_SQL = '2026-08-06 09:02:01';
+const VROEG_ISO = '2026-08-06T02:08:01.000Z';
+
+const insI = db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, published, created_at)
+                         VALUES (?,?,?,?,?,?,?,?)`);
+insI.run('reply', 'p1', 'https://elders.example/n/vroeg', 'https://elders.example/u/a', 'A', '<p>vroeg</p>', VROEG_ISO, VROEG_ISO);
+insI.run('reply', 'p1', 'https://elders.example/n/laat', 'https://elders.example/u/b', 'B', '<p>laat</p>', null, LAAT_SQL);
+
+const insT = db.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, author_name, content, published, created_at)
+                         VALUES (?,?,?,?,?,?,?)`);
+insT.run('https://elders.example/t/vroeg', 'kid', 'https://elders.example/u/a', 'A', '<p>vroeg</p>', VROEG_ISO, VROEG_ISO);
+insT.run('https://elders.example/t/laat', 'kid', 'https://elders.example/u/b', 'B', '<p>laat</p>', null, LAAT_SQL);
+
+const insM = db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, content, published, created_at)
+                         VALUES (?,?,?,?,?,?,?)`);
+insM.run('kid', 'https://elders.example/m/vroeg', 'https://elders.example/u/a', 'A', '<p>vroeg</p>', VROEG_ISO, VROEG_ISO);
+insM.run('kid', 'https://elders.example/m/laat', 'https://elders.example/u/b', 'B', '<p>laat</p>', null, LAAT_SQL);
+
+/** Het nieuwste eerst: hoort de LAAT-rij (09:02) te zijn, niet de ISO van 02:08. */
+const nieuwsteIs = (rijen, veld, verwacht) => {
+  assert.ok(rijen.length >= 2, 'beide rijen horen terug te komen');
+  assert.match(String(rijen[0][veld]), verwacht,
+    `de rij van 09:02 hoort bovenaan; kreeg ${rijen[0][veld]} -- dan sorteert de tekst en niet de tijd`);
+};
+
+test('de tijdlijn zet 09:02 boven 02:08, ook in twee spellingen', () => {
+  nieuwsteIs(AP.getTimeline('kid', 10, 0), 'id', /laat/);
+});
+
+test('de reacties onder een post ook -- de fout die gemeten werd', () => {
+  nieuwsteIs(AP.getReplyMessages('kid', 10), 'object_uri', /laat/);
+});
+
+test('en de berichten', () => {
+  nieuwsteIs(AP.getDirectMessages('kid', 10), 'object_uri', /laat/);
+});
+
+test('een onleesbare stempel valt niet weg, en bederft de rest niet', () => {
+  // strftime geeft NULL op iets dat het niet als tijd herkent; de COALESCE valt
+  // dan terug op de rauwe waarde. WAAR die rij landt is onbepaald -- als tekst
+  // staat 'geen datum' nu eenmaal boven '2026-...' -- en dat is de goede ruil:
+  // data die je niet begrijpt bewaar je. Wat hier telt is dat de rij blijft
+  // bestaan en dat de twee ECHTE spellingen onderling nog kloppen.
+  insT.run('https://elders.example/t/kapot', 'kid', 'https://elders.example/u/c', 'C', '<p>kapot</p>', 'geen datum', 'geen datum');
+  const rijen = AP.getTimeline('kid', 10, 0);
+  assert.equal(rijen.length, 3, 'de rij met de onleesbare stempel valt niet weg');
+  const ids = rijen.map((r) => r.id).filter((id) => !/kapot/.test(id));
+  assert.match(ids[0], /laat/, 'onder de leesbare rijen staat 09:02 nog steeds boven 02:08');
+});
