Index: scripts/backfill-reactions.mjs
===================================================================
--- scripts/backfill-reactions.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/backfill-reactions.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+//
+// Reactie-migratie handmatig draaien (shaer-9e9).
+//
+// Normaal hoef je dit NIET: migrateReactions() draait bij boot, één keer per
+// REACTIONS_MIGRATION_VERSION-bump, net als de self-heal. Dit script is er om
+// vooraf te kijken wat er zou gebeuren, of om het gericht op één instance te
+// forceren.
+//
+//     node scripts/backfill-reactions.mjs --dry-run
+//     node scripts/backfill-reactions.mjs            # respecteert de versievlag
+//     node scripts/backfill-reactions.mjs --force    # ook als de vlag al staat
+//
+// Bewust een schil om dezelfde functie die bij boot draait: twee implementaties
+// van een migratie lopen uiteen, en dan repareert de ene wat de andere niet ziet.
+//
+// Wat het doet, en waarom allebei nodig is:
+//
+//   HERSLEUTELEN  De oude interact-route bewaarde de URI waarmee je binnenkwam,
+//                 en de bookmarklet geeft de permalink door. Sinds de reacties
+//                 op de canonieke object-URI gezocht worden, zouden die rijen
+//                 wees zijn. De created_at reist mee.
+//   AANVULLEN     Alles wat op oude code via de Krant is gegeven staat alleen in
+//                 ap_timeline.liked/boosted. Zonder deze stap toont het als
+//                 niet-gereageerd -- en klikt iemand opnieuw, met een tweede
+//                 Like de fediverse in als gevolg.
+//   UIT REACTIES  De derde bron (shaer-ipb): ap_interactions.acted_like/_boost,
+//                 wat jij deed met een reactie ONDER je eigen post. Zelfde
+//                 gevolg als hierboven, alleen op een ander oppervlak.
+//
+// Wat het NIET kan: bij AANVULLEN de oorspronkelijke reactiedatum herstellen.
+// Wanneer je reageerde is nergens vastgelegd, dus die rijen krijgen de datum van
+// nu. Bij hersleutelen blijft de datum wel behouden.
+
+import db from '../src/config/database.js';
+import { migrateReactions } from '../src/services/ActivityPubService.js';
+
+const dryRun = process.argv.includes('--dry-run');
+const force = process.argv.includes('--force');
+
+const meet = () => ({
+  tussentabel: db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions').get().n,
+  liked: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE liked = 1').get().n,
+  boosted: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE boosted = 1').get().n,
+  scheef: db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_timeline t
+     WHERE (t.liked = 1 OR t.boosted = 1)
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n,
+  // Een reactie op een COMMENT hoort geen tijdlijnrij te hebben, dus die telt
+  // hier niet als wees -- anders slaat de controle hieronder alarm op precies
+  // wat stap 3 net goed heeft gezet.
+  wees: db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_my_reactions r
+     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t
+                        WHERE t.slug = r.site_slug AND t.id = r.target_uri)
+       AND NOT EXISTS (SELECT 1 FROM ap_interactions i WHERE i.object_uri = r.target_uri)`).get().n,
+  scheefActed: db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_interactions i
+     JOIN posts p ON p.id = i.post_id
+     JOIN sites s ON s.id = p.site_id
+     WHERE (i.acted_like = 1 OR i.acted_boost = 1) AND IFNULL(i.object_uri, '') <> ''
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri)`).get().n,
+});
+
+const voor = meet();
+console.log('vooraf :', JSON.stringify(voor));
+
+const uit = migrateReactions({ dryRun, force: force || dryRun });
+if (uit.overgeslagen) {
+  console.log('\novergeslagen: de versievlag staat al. Gebruik --force om toch te draaien.');
+  process.exit(0);
+}
+if (dryRun) {
+  console.log(`\n--dry-run: zou ${uit.hersleuteld} rij(en) hersleutelen, ${uit.aangevuld} aanvullen`
+    + ` en ${uit.reacties} uit reacties overnemen. Niets geschreven.`);
+  process.exit(0);
+}
+
+const na = meet();
+console.log('hersleuteld:', uit.hersleuteld, ' aangevuld:', uit.aangevuld, ' uit reacties:', uit.reacties);
+console.log('achteraf   :', JSON.stringify(na));
+
+// De twee controles die tellen. Blijft er een vlag zonder tegenhanger, dan is de
+// tussentabel niet compleet en tonen reacties als niet-gegeven. Blijft er een
+// tussentabel-rij zonder tijdlijnrij, dan is die op iets buiten je tijdlijn
+// gericht (legitiem) OF nog op een permalink (niet legitiem) -- vandaar de
+// waarschuwing in plaats van een fout.
+if (na.scheef !== 0) {
+  console.error(`\nFOUT: nog ${na.scheef} rij(en) met een vlag zonder tegenhanger.`);
+  process.exit(1);
+}
+if (na.scheefActed !== 0) {
+  console.error(`\nFOUT: nog ${na.scheefActed} reactie(s) onder je eigen posts met acted_* zonder tegenhanger.`);
+  process.exit(1);
+}
+if (na.wees > voor.wees) {
+  console.error('\nFOUT: er zijn tussentabel-rijen bijgekomen die nergens op slaan.');
+  process.exit(1);
+}
+if (na.wees) {
+  console.warn(`\nLET OP: ${na.wees} tussentabel-rij(en) zonder tijdlijnrij. Dat mag (een reactie op iets\n`
+    + 'buiten je tijdlijn), maar controleer of er geen permalinks tussen zitten van een post die je\n'
+    + 'wel kent -- die zouden hersleuteld moeten zijn.');
+}
+console.log('\nOK: elke vlag heeft een tegenhanger in de tussentabel.');
Index: scripts/export-archive.mjs
===================================================================
--- scripts/export-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/export-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+//
+// Exporteer het inhoudsarchief van een site (shaer-1a6).
+//
+//     node scripts/export-archive.mjs <slug> --dry-run
+//     node scripts/export-archive.mjs <slug> --out boiert.zip
+//     node scripts/export-archive.mjs <slug> --dir ./archief
+//
+// Het formaat staat in docs/EXPORT-FORMAT.md.
+//
+// Dit is NIET de storage-zip: er zit geen sleutel, sessie, wachtwoordhash of
+// DM van een ander in. Wel je eigen concepten, en -- als je die hebt -- de
+// volledige inhoud van betaalde posts. Behandel het bestand als de inhoud zelf.
+
+import fs from 'fs';
+import path from 'path';
+import { buildArchive, zipArchive, writeArchiveDir } from '../src/services/ArchiveExportService.js';
+
+const args = process.argv.slice(2);
+const slug = args.find((a) => !a.startsWith('-'));
+const vlag = (naam) => { const i = args.indexOf(naam); return i >= 0 ? (args[i + 1] || true) : null; };
+
+if (!slug) {
+  console.error('gebruik: node scripts/export-archive.mjs <slug> [--dry-run | --out <zip> | --dir <map>]');
+  process.exit(1);
+}
+
+const uit = buildArchive(slug);
+const t = uit.counts;
+console.log(`site      : ${uit.manifest.site.slug} (${uit.manifest.origin || 'geen PUBLIC_BASE_URL'})`);
+console.log(`posts     : ${t.posts}`);
+console.log(`antwoorden: ${t.replies}   (alleen-lezen archief)`);
+console.log(`media     : ${t.media} meegenomen, ${t.mediaMissing} ontbrekend`);
+
+// Ontbrekende media worden GETELD en GEMELD, nooit stil overgeslagen: een post
+// waarvan het plaatje verdwenen is hoort dat te zeggen.
+if (uit.missing.length) {
+  console.log('\nontbrekende media:');
+  for (const m of uit.missing.slice(0, 20)) console.log(`  ${m.post}  ${m.url}`);
+  if (uit.missing.length > 20) console.log(`  ... en nog ${uit.missing.length - 20}`);
+}
+
+if (args.includes('--dry-run')) {
+  console.log(`\n--dry-run: ${uit.files.size} bestand(en) zouden geschreven worden. Niets geschreven.`);
+  process.exit(0);
+}
+
+const dir = vlag('--dir');
+if (dir && typeof dir === 'string') {
+  writeArchiveDir(uit.files, dir);
+  console.log(`\ngeschreven naar ${path.resolve(dir)}`);
+  process.exit(0);
+}
+
+const out = (typeof vlag('--out') === 'string' ? vlag('--out') : null) || `${slug}-archief.zip`;
+fs.writeFileSync(out, zipArchive(uit.files));
+console.log(`\ngeschreven: ${out} (${fs.statSync(out).size} bytes)`);
Index: scripts/import-archive.mjs
===================================================================
--- scripts/import-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/import-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+//
+// Importeer een inhoudsarchief in een site (shaer-pmr).
+//
+//     node scripts/import-archive.mjs <slug> <archief.zip|map> --dry-run
+//     node scripts/import-archive.mjs <slug> <archief.zip|map>
+//     node scripts/import-archive.mjs <slug> <archief.zip|map> --overwrite
+//
+// Het formaat staat in docs/EXPORT-FORMAT.md.
+//
+// Standaard wordt een bestaande post met hetzelfde id of dezelfde slug
+// OVERGESLAGEN. Dat maakt de import idempotent en zorgt dat je nooit per ongeluk
+// inhoud vernietigt die er al staat. --overwrite doet het wel, expliciet.
+//
+// Er gaat GEEN Update de fediverse in. Als andere servers een oudere kopie
+// hebben, blijft die daar staan; dat rechttrekken is een uitzending naar iedereen
+// en hoort een aparte, bewuste actie te zijn.
+
+import { readArchive, importArchive } from '../src/services/ArchiveImportService.js';
+
+const args = process.argv.slice(2);
+const vrij = args.filter((a) => !a.startsWith('-'));
+const [slug, bron] = vrij;
+
+if (!slug || !bron) {
+  console.error('gebruik: node scripts/import-archive.mjs <slug> <archief.zip|map> [--dry-run] [--overwrite]');
+  process.exit(1);
+}
+
+let files;
+try { files = readArchive(bron); }
+catch (e) { console.error(`kan het archief niet lezen: ${e.message}`); process.exit(1); }
+
+let r;
+try { r = importArchive(files, { slug, dryRun: args.includes('--dry-run'), overwrite: args.includes('--overwrite') }); }
+catch (e) { console.error(`geweigerd: ${e.message}`); process.exit(1); }
+
+console.log(`formaatversie : ${r.formatVersion}`);
+console.log(`herkomst      : ${r.origin || '(geen)'}`);
+console.log(`AP-ids        : ${r.idsBehouden ? 'BEHOUDEN (zelfde origin)' : 'nieuw (andere origin)'}`);
+console.log(`posts         : ${r.posts}${r.overschreven ? `, waarvan ${r.overschreven} overschreven` : ''}`);
+console.log(`overgeslagen  : ${r.overgeslagen}   (bestonden al)`);
+console.log(`antwoorden    : ${r.replies}   (alleen-lezen archief)`);
+console.log(`media         : ${r.media} teruggezet, ${r.mediaMissing} ontbrekend`);
+
+if (r.gemist.length) {
+  console.log('\nontbrekende media:');
+  for (const m of r.gemist.slice(0, 20)) console.log(`  ${m.post}  ${m.url}`);
+  if (r.gemist.length > 20) console.log(`  ... en nog ${r.gemist.length - 20}`);
+}
+for (const w of r.waarschuwingen) console.log(`\nLET OP: ${w}`);
+
+if (args.includes('--dry-run')) console.log('\n--dry-run: niets geschreven.');
Index: scripts/klonkt-refresh-updater.sh
===================================================================
--- scripts/klonkt-refresh-updater.sh	(revision 12bed59eaefe12a4ed9c2c3cc267cb448ae42b43)
+++ scripts/klonkt-refresh-updater.sh	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -30,5 +30,14 @@
 D="${KLONKT_DIR}"
 B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true)
-runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${BRANCH}
+# Deepen a shallow checkout ONCE. \`fetch --depth 1\` keeps only the newest commit
+# and \`checkout -f\` throws away the tree that was there, so after an update the
+# previous version existed nowhere on the machine: a bad release could not be
+# undone without the network, and only if you knew which commit to ask for.
+# One deepening buys back the history; every later fetch keeps it.
+if [ "\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then
+  echo "deepening the checkout once so updates can be rolled back..."
+  runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --unshallow origin ${BRANCH} || true
+fi
+runuser -u ${KLONKT_USER} -- git -C "\$D" fetch origin ${BRANCH}
 runuser -u ${KLONKT_USER} -- git -C "\$D" checkout -qf -B ${BRANCH} FETCH_HEAD
 A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD)
@@ -54,4 +63,13 @@
 else
   echo "Klonkt updated (\$A) + restarted \$N instance(s)."
+fi
+# History alone is not a rollback; at three in the morning you also need the
+# command. Print it while the previous commit is still known.
+if [ -n "\$B" ]; then
+  echo
+  echo "Previous version: \$B"
+  echo "To go back:"
+  echo "  runuser -u ${KLONKT_USER} -- git -C \$D checkout -qf -B ${BRANCH} \$B"
+  echo "  then restart the instances (systemctl restart 'klonkt@*')"
 fi
 EOF
Index: scripts/recover-from-cache.mjs
===================================================================
--- scripts/recover-from-cache.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/recover-from-cache.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+//
+// Herstel de posts van een verloren Klonkt uit de tijdlijn-cache van instances
+// die haar volgden (shaer-l1v).
+//
+//     node scripts/recover-from-cache.mjs \
+//       --actor https://boiert.eu/ap/users/boiert \
+//       --from /pad/naar/sound-fabrics/database.sqlite \
+//       --from /pad/naar/nog-een/database.sqlite \
+//       --media /geredde/storage/media \
+//       --audio /geredde/storage/audio \
+//       --out boiert-herstel.zip
+//
+// Het resultaat is een gewoon archief in het formaat uit docs/EXPORT-FORMAT.md.
+// Terugzetten gaat dus met scripts/import-archive.mjs, met dezelfde droogloop en
+// dezelfde regel rond het behouden van AP-ids. Er is bewust geen apart
+// herstelpad: dat zou een tweede implementatie zijn van iets dat al bestaat.
+//
+// WAT HIER PRINCIPIEEL NIET IN ZIT:
+//   - antwoorden van de verloren site zelf (die staan niet in een tijdlijn-cache)
+//   - alles van voor het moment dat de bron ging volgen
+//   - concepten (nooit gefedereerd, dus nergens gecachet)
+//   - de plek van afbeeldingen IN de tekst (bij het federeren eruit gehaald)
+
+import fs from 'fs';
+import path from 'path';
+import { recoverFromCache } from '../src/services/ArchiveRecoveryService.js';
+import { zipArchive } from '../src/services/ArchiveExportService.js';
+
+const args = process.argv.slice(2);
+const alle = (naam) => args.reduce((uit, a, i) => (a === naam && args[i + 1] ? [...uit, args[i + 1]] : uit), []);
+const een = (naam) => alle(naam)[0] || null;
+
+const actor = een('--actor');
+const sources = alle('--from');
+if (!actor || !sources.length) {
+  console.error('gebruik: node scripts/recover-from-cache.mjs --actor <actor-uri> --from <db> [--from <db>] [--media <map>] [--audio <map>] [--out <zip> | --dir <map>] [--houd-titel]');
+  process.exit(1);
+}
+
+let uit;
+try {
+  uit = recoverFromCache({
+    sources, actorUri: actor, mediaRoot: een('--media'), audioRoot: een('--audio'),
+    houdTitelInTekst: args.includes('--houd-titel'),
+    slug: een('--slug') || '', title: een('--titel') || '',
+  });
+} catch (e) { console.error(`kan niet herstellen: ${e.message}`); process.exit(1); }
+
+const r = uit.rapport;
+console.log('bronnen:');
+for (const b of r.bronnen) console.log(`  ${b.rijen.toString().padStart(5)} rij(en)  ${b.pad}`);
+console.log(`\nposts gevonden : ${r.posts}`);
+console.log(`periode        : ${r.oudste || '?'}  tot  ${r.nieuwste || '?'}`);
+console.log(`media          : ${r.media} gered, ${r.mediaMissing} niet gevonden`);
+console.log(`titels          : ${r.titels.length} losgetrokken uit de tekst`);
+
+// De titels horen door een MENS nagelopen te worden. Er is geen sluitend signaal
+// dat een vetgedrukte eerste regel een titel was; de slug is er niet van afgeleid.
+if (r.titels.length) {
+  console.log('\nteruggevonden titels -- loop deze na, een post die echt met een vetgedrukte');
+  console.log('regel begint raakt die regel kwijt aan zijn titel:');
+  for (const t of r.titels.slice(0, 30)) console.log(`  ${t.slug.padEnd(28)} ${t.titel}`);
+  if (r.titels.length > 30) console.log(`  ... en nog ${r.titels.length - 30}`);
+}
+if (r.gemist.length) {
+  console.log('\nmedia niet gevonden op schijf:');
+  for (const m of r.gemist.slice(0, 20)) console.log(`  ${m.slug.padEnd(28)} ${m.url}`);
+  if (r.gemist.length > 20) console.log(`  ... en nog ${r.gemist.length - 20}`);
+}
+for (const w of r.waarschuwingen) console.log(`\nLET OP: ${w}`);
+
+console.log('\nNIET herstelbaar uit een tijdlijn-cache: eigen antwoorden, alles van voor het');
+console.log('volgmoment, concepten, en de plek van afbeeldingen in de tekst.');
+
+if (args.includes('--dry-run')) {
+  console.log(`\n--dry-run: ${uit.files.size} bestand(en) zouden geschreven worden. Niets geschreven.`);
+  process.exit(0);
+}
+
+const dir = een('--dir');
+if (dir) {
+  for (const pad of [...uit.files.keys()].sort()) {
+    const doel = path.join(dir, pad);
+    fs.mkdirSync(path.dirname(doel), { recursive: true });
+    fs.writeFileSync(doel, uit.files.get(pad));
+  }
+  console.log(`\ngeschreven naar ${path.resolve(dir)}`);
+  process.exit(0);
+}
+
+const out = een('--out') || 'herstel.zip';
+fs.writeFileSync(out, zipArchive(uit.files));
+console.log(`\ngeschreven: ${out} (${fs.statSync(out).size} bytes)`);
+console.log(`\nterugzetten:  node scripts/import-archive.mjs <slug> ${out} --dry-run`);
