Index: docs/EXPORT-FORMAT.md
===================================================================
--- docs/EXPORT-FORMAT.md	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
+++ docs/EXPORT-FORMAT.md	(revision 812fea90d4ea584a6d5809ffc45c9d527d45b7e7)
@@ -99,8 +99,8 @@
 | `summary` | `posts.content_warning` | AS2 summary is the content warning |
 | `sensitive` | `posts.nsfw` | |
-| `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC |
+| `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC. Klonkt stores timestamps in two spellings (`YYYY-MM-DD HH:MM:SS` and full ISO); the archive normalises to ISO. The **instant** survives a round trip, the spelling does not. |
 | `updated` | `posts.updated_at` | omitted when equal to `published` |
 | `url` | `<origin>/<slug>` | the human permalink |
-| `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media) |
+| `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media); each carries a `shaer:role` |
 | `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` |
 | `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` |
@@ -170,4 +170,18 @@
 | `missing` | it existed, we know where, we do not have it | the original absolute URL |
 
+Every attachment also carries **`shaer:role`**, saying what it was for:
+
+| role | restored to |
+|---|---|
+| `cover` / `coverVideo` | `posts.cover_image_url` / `cover_video_url` |
+| `inline` | already referenced from `content` |
+| `c2s` | an entry in `posts.c2s_attachments` |
+| `poster` | the `poster` of the `c2s` entry named in `shaer:posterFor` |
+| `track` | the file behind a `[[track:id]]`, linked from `shaer:audio` |
+
+The role is not decoration. Without it the archive holds the bytes but not the
+fact that they *were the cover*, and the post comes back without one — which is
+invisible until you compare every column, not a handful.
+
 `url` for an included attachment is a **container-relative path**, not a URL. An
 importer must rewrite it. This is the one place where the archive deviates from
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
+++ src/services/ArchiveExportService.js	(revision 812fea90d4ea584a6d5809ffc45c9d527d45b7e7)
@@ -81,20 +81,25 @@
   const uit = [];
   const zie = new Set();
-  const voegToe = (url, name) => {
+  const voegToe = (url, name, rol, extra = {}) => {
     const u = String(url || '').trim();
     if (!u || zie.has(u)) return;
     zie.add(u);
-    uit.push({ url: u, name: name || null });
+    uit.push({ url: u, name: name || null, rol, ...extra });
   };
-  voegToe(post.cover_image_url, post.cover_alt);
-  voegToe(post.cover_video_url, post.cover_alt);
-  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1]);
+  // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
+  // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
+  // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
+  // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
+  // handjevol.
+  voegToe(post.cover_image_url, post.cover_alt, 'cover');
+  voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
+  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
   try {
     for (const a of JSON.parse(post.c2s_attachments || '[]')) {
-      voegToe(a && a.url, a && a.name);
+      voegToe(a && a.url, a && a.name, 'c2s');
       // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
       // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
       // pas op bij de export van echte data.
-      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null);
+      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
     }
   } catch { /* kapotte kolom blokkeert de export niet */ }
@@ -103,5 +108,5 @@
     try {
       const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
-      if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title);
+      if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
     } catch { /* geen audio-tabellen: niets te doen */ }
   }
@@ -251,4 +256,6 @@
           'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
           'shaer:sha256': hash,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
         });
       } else {
@@ -261,4 +268,6 @@
           type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
           url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
         });
       }
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
+++ src/services/ArchiveImportService.js	(revision 812fea90d4ea584a6d5809ffc45c9d527d45b7e7)
@@ -28,5 +28,8 @@
 
 const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-const sqlTijd = (iso) => { const t = Date.parse(iso); return isNaN(t) ? null : new Date(t).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); };
+// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
+// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
+// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
+const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
 
 // ── Inlezen ───────────────────────────────────────────────────────
@@ -220,8 +223,8 @@
     (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
      pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
-     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, origin_server)
+     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
     VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
      @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
-     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, 'import')`);
+     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
   const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
     (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
@@ -242,13 +245,28 @@
       const o = s.obj;
       const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
+      // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
+      // bestand er wel, maar komt de post zonder cover en zonder speler terug --
+      // en dat zie je pas als je alle kolommen vergelijkt.
+      const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
+      const padVan = (a) => (a ? padVanOrigineel(a['shaer:originalUrl']) : null);
+      const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
+      const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
+        const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
+        return {
+          url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
+          poster: poster ? padVan(poster) : undefined,
+        };
+      }).filter((a) => a.url);
       insPost.run({
         id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
         title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
         status: o['shaer:status'] || 'draft',
-        cover_image_url: null, cover_alt: o['shaer:coverAlt'] || null, cover_video_url: null,
+        cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
+        cover_video_url: padVan(metRol('coverVideo')),
+        c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
         pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
         tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
-        published_at: sqlTijd(o.published), created_at: sqlTijd(o.published), updated_at: sqlTijd(o.updated || o.published),
-        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: sqlTijd(o['shaer:publishAt']),
+        published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
+        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
         fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
         language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
Index: test/archive-import.test.js
===================================================================
--- test/archive-import.test.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
+++ test/archive-import.test.js	(revision 812fea90d4ea584a6d5809ffc45c9d527d45b7e7)
@@ -292,4 +292,48 @@
 });
 
+test('de cover en de speler komen terug, niet alleen hun bestanden', () => {
+  // Gevonden bij het oefenherstel, door ALLE kolommen te vergelijken in plaats
+  // van een handjevol: de bytes zaten in het archief, maar nergens stond dat ze
+  // de cover waren. De post kwam zonder cover en zonder speler terug.
+  leeg();
+  fs.mkdirSync(path.join(MEDIA, 'c'), { recursive: true });
+  for (const [n, b] of [['cov.jpg', 'cover'], ['op.m4a', 'audio'], ['op.png', 'poster']]) {
+    fs.writeFileSync(path.join(MEDIA, 'c', n), Buffer.from(b));
+  }
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, cover_image_url, cover_alt, c2s_attachments)
+              VALUES ('rijk','s1','rijke-post','u1','Rijk','<p>x</p>','published','2026-08-05 10:00:00',
+              '/media/c/cov.jpg','de cover', ?)`)
+    .run(JSON.stringify([{ url: '/media/c/op.m4a', mediaType: 'audio/mp4', name: 'opname.m4a', poster: '/media/c/op.png' }]));
+
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  const rollen = JSON.parse(arch.files.get('posts/rijk.json').toString()).attachment.map((a) => a['shaer:role']);
+  assert.deepEqual(rollen.sort(), ['c2s', 'cover', 'poster'], 'elke bijlage zegt waar hij voor was');
+
+  db.prepare('DELETE FROM posts').run();
+  fs.rmSync(path.join(MEDIA, 'c'), { recursive: true, force: true });
+  AI.importArchive(arch.files, { slug: 'me' });
+
+  const p = db.prepare("SELECT * FROM posts WHERE id = 'rijk'").get();
+  assert.equal(p.cover_image_url, '/media/c/cov.jpg', 'zonder dit staat de post zonder cover terug');
+  assert.equal(p.cover_alt, 'de cover');
+  const c2s = JSON.parse(p.c2s_attachments);
+  assert.equal(c2s[0].url, '/media/c/op.m4a');
+  assert.equal(c2s[0].poster, '/media/c/op.png', 'de poster hoort weer bij zijn opname');
+  assert.ok(fs.existsSync(path.join(MEDIA, 'c', 'cov.jpg')));
+});
+
+test('het MOMENT van een tijdstempel overleeft, ook uit SQL-notatie', () => {
+  // Klonkt schrijft in twee spellingen. Het archief normaliseert naar ISO; het
+  // moment blijft, de spelling niet. Dat is gedocumenteerd gedrag, geen verlies.
+  leeg();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
+              VALUES ('tijd','s1','tijd','u1','T','<p>x</p>','published','2026-07-01 12:56:10')`).run();
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  db.prepare('DELETE FROM posts').run();
+  AI.importArchive(arch.files, { slug: 'me' });
+  const p = db.prepare("SELECT published_at FROM posts WHERE id = 'tijd'").get();
+  assert.equal(Date.parse(p.published_at), Date.parse('2026-07-01T12:56:10Z'));
+});
+
 test('zonder manifest is het geen archief', () => {
   const files = new Map(ARCHIEF.files);
