Index: src/routes/admin-circle.js
===================================================================
--- src/routes/admin-circle.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,139 +1,0 @@
-/**
- * Admin: Circle management (god-only).
- *   GET  /admin/circle           -> list of circle links + status
- *   POST /admin/circle/add       -> add a Klonkt URL
- *   POST /admin/circle/:id/remove
- *   POST /admin/circle/:id/sync  -> refresh now (pull + verify)
- *   POST /admin/circle/allow     -> toggle "may appear in others' circles"
- *
- * See docs/cirkels-v1-spec.md §5d.
- */
-
-import express from 'express';
-import crypto from 'crypto';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import db from '../config/database.js';
-import { getTenancy } from '../services/SettingsService.js';
-import { syncOne, sync } from '../services/CircleService.js';
-
-const router = express.Router();
-
-function primarySite() {
-  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
-}
-
-router.get('/', requireGod, (req, res) => {
-  const site = primarySite();
-  const links = site
-    ? db.prepare('SELECT * FROM circle_links WHERE local_site_id = ? ORDER BY added_at DESC').all(site.id)
-    : [];
-  const counts = {};
-  for (const l of links) {
-    counts[l.id] = l.remote_actor_id
-      ? db.prepare('SELECT COUNT(*) AS n FROM remote_posts WHERE actor_id = ?').get(l.remote_actor_id).n
-      : 0;
-  }
-  renderPage(req, res, 'pages/admin-circle', {
-    pageTitle: 'Cirkel',
-    bodyClass: 'on-admin',
-    tenancy: getTenancy(),
-    site,
-    links,
-    counts,
-    allowCircle: site ? site.allow_circle !== 0 : true,
-    success: req.query.success || null,
-    error: req.query.error || null,
-  });
-});
-
-router.post('/add', requireGod, async (req, res) => {
-  const site = primarySite();
-  if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
-  // Auto-complete the scheme: a bare domain name → https://, a typed
-  // http:// → https:// (federation is intentionally https-only, signed feeds). So the
-  // user never has to type http(s):// themselves.
-  let url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
-  if (url && !/^[a-z]+:\/\//i.test(url)) url = 'https://' + url;
-  url = url.replace(/^http:\/\//i, 'https://');
-  if (!/^https:\/\/[^\s/]+(\/[^\s]*)?$/i.test(url)) {
-    return res.redirect('/admin/circle?error=' + encodeURIComponent('Voer een geldig site-adres in (bv. voorbeeld.nl)'));
-  }
-  const label = (req.body.label || '').toString().slice(0, 80).trim() || null;
-  const id = crypto.randomUUID();
-  try {
-    db.prepare("INSERT INTO circle_links (id, local_site_id, remote_url, label, status) VALUES (?, ?, ?, ?, 'active')")
-      .run(id, site.id, url, label);
-  } catch (e) {
-    return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
-  }
-  // Fetch immediately instead of waiting for the 15-minute loop.
-  try {
-    const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(id);
-    await syncOne(link);
-    return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd en gesynchroniseerd ✓'));
-  } catch (e) {
-    const msg = String((e && e.message) || e);
-    // 404 = no circle endpoint. Hubs intentionally do NOT federate (their /.klonkt/actor.json
-    // returns 404), same as standalone non-Klonkt sites. Don't add: roll back the insert
-    // so no dead "error" row is left in the circle.
-    if (/\b404\b/.test(msg)) {
-      db.prepare('DELETE FROM circle_links WHERE id = ?').run(id);
-      return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet toegevoegd: deze site doet niet mee aan cirkels. Een hub kan geen cirkel-partner zijn (en losse/niet-Klonkt-sites ook niet).'));
-    }
-    // Other (possibly temporary) error → link stays; use "Refresh" later to retry.
-    return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — synchroniseren mislukte (klik "Verversen" om opnieuw te proberen)'));
-  }
-});
-
-router.post('/:id/remove', requireGod, (req, res) => {
-  const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
-  if (link) {
-    db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id);
-    // Clean up cached content if no other link still points to this actor.
-    if (link.remote_actor_id) {
-      const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
-      if (!other) {
-        db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id);
-        db.prepare('DELETE FROM remote_actors WHERE id = ?').run(link.remote_actor_id);
-      }
-    }
-  }
-  res.redirect('/admin/circle?success=' + encodeURIComponent('Verwijderd'));
-});
-
-router.post('/:id/sync', requireGod, async (req, res) => {
-  const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
-  if (!link) return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet gevonden'));
-  try {
-    const r = await syncOne(link);
-    res.redirect('/admin/circle?success=' + encodeURIComponent(`Bijgewerkt — ${r.items} posts opgehaald`));
-  } catch (e) {
-    const msg = String((e && e.message) || e).slice(0, 300);
-    db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
-      .run(msg, link.id);
-    res.redirect('/admin/circle?error=' + encodeURIComponent(msg));
-  }
-});
-
-// Refresh everything at once (handy "just to be sure").
-router.post('/sync-all', requireGod, async (req, res) => {
-  try {
-    const r = await sync();
-    const n = (r && r.results) ? r.results.filter((x) => x && x.ok).length : 0;
-    res.redirect('/admin/circle?success=' + encodeURIComponent(`Cirkel gesynchroniseerd (${n} site(s) bijgewerkt)`));
-  } catch (e) {
-    res.redirect('/admin/circle?error=' + encodeURIComponent(String((e && e.message) || e).slice(0, 200)));
-  }
-});
-
-router.post('/allow', requireGod, (req, res) => {
-  const site = primarySite();
-  if (site) {
-    const v = (req.body.allow_circle === 'on' || req.body.allow_circle === '1') ? 1 : 0;
-    db.prepare('UPDATE sites SET allow_circle = ? WHERE id = ?').run(v, site.id);
-  }
-  res.redirect('/admin/circle?success=' + encodeURIComponent('Opgeslagen'));
-});
-
-export default router;
Index: src/routes/changelog.js
===================================================================
--- src/routes/changelog.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/routes/changelog.js	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -3,9 +3,4 @@
  *
  * GET /changelog  -> renders CHANGELOG.md (the source of truth for releases).
- *
- * The app version (footer, package.json) is intentionally decoupled from the
- * circle federation proto (KLONKT_PROTO): a version bump is cosmetic and does not
- * affect federation. We show the proto here explicitly so that each release
- * makes visible which federation version this instance speaks (circles = lockstep per proto).
  */
 
@@ -16,5 +11,4 @@
 import { renderPage } from '../middleware/render.js';
 import { MarkdownService } from '../services/MarkdownService.js';
-import { KLONKT_PROTO } from '../services/CircleFederation.js';
 
 const router = express.Router();
@@ -33,5 +27,4 @@
     bodyClass: 'on-changelog',
     changelogHtml: html,
-    proto: KLONKT_PROTO,
   });
 });
Index: src/routes/circle.js
===================================================================
--- src/routes/circle.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/routes/circle.js	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -1,21 +1,15 @@
 /**
- * Circle feed + local reading page.
- *   GET /cirkel        -> overview (same timeline/grid view as the home)
- *   GET /cirkel/:id    -> individual remote post in own chrome (stay on your site)
- * Only active when tenancy === 'circle' (otherwise next() -> postsRoutes/404).
- * See docs/cirkels-v1-spec.md §5c.
+ * Circle feed — the artists this site features (auto-boosts), sourced from
+ * ActivityPub. Cards link to the source post. Available whenever the site
+ * auto-boosts at least one account; otherwise next() -> postsRoutes.
+ *   GET /cirkel
  */
 
 import express from 'express';
 import { renderPage } from '../middleware/render.js';
-import db from '../config/database.js';
-import { getTenancy, apEnabled } from '../services/SettingsService.js';
+import { apEnabled } from '../services/SettingsService.js';
 import ActivityPubService from '../services/ActivityPubService.js';
 
 const router = express.Router();
-
-function htmlToText(html) {
-  return String(html || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-}
 
 function safeUrl(u) {
@@ -27,115 +21,40 @@
 function mediaImage(media_json) {
   const media = safeJson(media_json).map((m) => ({ ...m, url: safeUrl(m.url) })).filter((m) => m.url);
-  return media.find((m) => m.type === 'image') || null;
+  return media.find((m) => /image/i.test(m.type || '')) || media[0] || null;
+}
+function htmlToText(html) {
+  return String(html || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
 }
 
-// ── Overview ─────────────────────────────────────────────────
-// New model: the Cirkel = posts from the accounts this site auto-boosts
-// ("feature an artist"), sourced from ActivityPub. Cards link to the source
-// post (external_url). Legacy circle-tenancy remote_posts are merged in until
-// the old pull-protocol is removed (Phase 4).
 router.get('/cirkel', (req, res, next) => {
   const site = res.locals.site;
-  if (!site) return next();
-  const slug = site.slug;
-  const isCircle = getTenancy() === 'circle';
-  const abCount = apEnabled() ? ActivityPubService.autoBoostCount(slug) : 0;
-  if (!abCount && !isCircle) return next(); // no cirkel on this site
+  if (!site || !apEnabled() || ActivityPubService.autoBoostCount(site.slug) === 0) return next();
 
-  let posts = [];
+  const posts = ActivityPubService.getCirkelPosts(site.slug, 80).map((r) => {
+    const text = htmlToText(r.content);
+    const image = mediaImage(r.media_json);
+    const name = r.author_name || r.author_handle || 'Onbekend';
+    return {
+      id: 'ap-' + r.id,
+      slug: '',
+      title: text ? (text.length > 90 ? text.slice(0, 90) + '…' : text) : name,
+      excerpt: '',
+      cover_image_url: image ? image.url : null,
+      published_at: r.published,
+      created_at: r.published,
+      type: 'post',
+      tags: '',
+      pinned: 0,
+      status: 'published',
+      source_name: name,
+      external_url: safeUrl(r.url),
+    };
+  });
 
-  // Featured (auto-boosted) fediverse posts → link to the original.
-  if (abCount) {
-    posts = ActivityPubService.getCirkelPosts(slug, 80).map((r) => {
-      const text = htmlToText(r.content);
-      const image = mediaImage(r.media_json);
-      const name = r.author_name || r.author_handle || 'Onbekend';
-      return {
-        id: 'ap-' + r.id,
-        slug: '',
-        title: text ? (text.length > 90 ? text.slice(0, 90) + '…' : text) : name,
-        excerpt: '',
-        cover_image_url: image ? image.url : null,
-        published_at: r.published,
-        created_at: r.published,
-        type: 'post',
-        tags: '',
-        pinned: 0,
-        status: 'published',
-        source_name: name,
-        external_url: safeUrl(r.url),
-      };
-    });
-  }
-
-  // Legacy circle-tenancy remote_posts (local reading page, no external_url).
-  if (isCircle) {
-    const rows = db.prepare(`
-      SELECT p.id, p.published, p.title, p.summary, p.media_json, p.tags, a.name AS actor_name
-      FROM remote_posts p JOIN remote_actors a ON a.id = p.actor_id
-      ORDER BY COALESCE(p.published, p.fetched_at) DESC LIMIT 100
-    `).all().map((r) => {
-      const image = mediaImage(r.media_json);
-      return {
-        id: r.id, slug: 'cirkel/' + encodeURIComponent(r.id),
-        title: r.title || '(zonder titel)', excerpt: r.summary || '',
-        cover_image_url: image ? image.url : null, published_at: r.published, created_at: r.published,
-        type: 'post', tags: r.tags || '', pinned: 0, status: 'published', source_name: r.actor_name || 'Onbekend',
-      };
-    });
-    posts = posts.concat(rows);
-  }
-
-  posts.sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')));
-
-  // Members header (avatars): featured artists + legacy circle links.
-  let sites = abCount
-    ? ActivityPubService.getCirkelMembers(slug).map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.icon) }))
-    : [];
-  if (isCircle) {
-    const old = db.prepare(`
-      SELECT a.name, a.url, a.avatar FROM remote_actors a
-      JOIN circle_links l ON l.remote_actor_id = a.id WHERE l.status = 'active' ORDER BY a.name
-    `).all().map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.avatar) }));
-    sites = sites.concat(old);
-  }
+  const sites = ActivityPubService.getCirkelMembers(site.slug)
+    .map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.icon) }));
 
   renderPage(req, res, 'pages/circle-feed', { pageTitle: 'Cirkel', bodyClass: 'on-cirkel', posts, sites });
 });
 
-// ── Individual remote post (local reading) ────────────────────
-router.get('/cirkel/:id', (req, res, next) => {
-  if (getTenancy() !== 'circle') return next();
-
-  const row = db.prepare(`
-    SELECT p.id, p.published, p.title, p.summary, p.url, p.media_json, p.tags,
-           a.name AS actor_name, a.url AS actor_url, a.avatar AS actor_avatar
-    FROM remote_posts p
-    JOIN remote_actors a ON a.id = p.actor_id
-    WHERE p.id = ?
-  `).get(req.params.id);
-
-  if (!row) return next();
-
-  const image = mediaImage(row.media_json);
-  const post = {
-    title: row.title || '(zonder titel)',
-    body: row.summary || '',          // platte tekst (gesanitized bij ingest)
-    published: row.published,
-    image: image ? image.url : null,
-    sourceName: row.actor_name || 'Onbekend',
-    sourceUrl: safeUrl(row.actor_url),
-    sourceAvatar: safeUrl(row.actor_avatar),
-    originalUrl: safeUrl(row.url),
-    tags: (row.tags || '').split(',').map((t) => t.trim()).filter(Boolean),
-    sourceTagBase: safeUrl(row.actor_url) ? safeUrl(row.actor_url).replace(/\/+$/, '') : null,
-  };
-
-  renderPage(req, res, 'pages/circle-post', {
-    pageTitle: post.title,
-    bodyClass: 'on-cirkel-post',
-    post,
-  });
-});
-
 export default router;
Index: src/routes/federation.js
===================================================================
--- src/routes/federation.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,55 +1,0 @@
-// routes/federation.js — public Cirkels endpoints (v1, publication side).
-//
-//   GET /.klonkt/actor.json   — ActivityStreams actor + Ed25519 public key
-//   GET /.klonkt/outbox.json  — public posts as AS Create objects,
-//                               signed via the Klonkt-Signature header
-//
-// Site-agnostic and unauthenticated — read-only. See docs/cirkels-v1-spec.md.
-
-import express from 'express';
-import { buildActor, buildOutbox, signBody, KLONKT_PROTO, MIN_PROTO } from '../services/CircleFederation.js';
-import { getTenancy } from '../services/SettingsService.js';
-
-const router = express.Router();
-
-function baseUrl(req) {
-  const b = process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`;
-  return b.replace(/\/+$/, '');
-}
-
-// The proto the consumer claims to be running (from their request header), or 0.
-function consumerProto(req) {
-  return parseInt(req.get('Klonkt-Proto') || '0', 10) || 0;
-}
-
-router.get('/.klonkt/actor.json', (req, res) => {
-  // Circles = solo-to-solo; hubs do not publish a federation actor.
-  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
-  // We ALWAYS serve the actor (including to older consumers) so they can read our
-  // proto and show a clean "update required" message.
-  const body = JSON.stringify(buildActor(baseUrl(req)), null, 2);
-  res.type('application/activity+json; charset=utf-8');
-  res.set('Klonkt-Proto', String(KLONKT_PROTO));
-  res.set('Cache-Control', 'public, max-age=300');
-  res.send(body);
-});
-
-router.get('/.klonkt/outbox.json', (req, res) => {
-  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
-  res.set('Klonkt-Proto', String(KLONKT_PROTO));
-  // Consumer too old? Reject with 426 Upgrade Required (the crypto binding already
-  // excludes them; this gives an explicit, readable signal). proto 0 = no header
-  // (e.g. a browser/curl) → allow, they won't verify anyway.
-  const cp = consumerProto(req);
-  if (cp && cp < MIN_PROTO) {
-    return res.status(426).type('text/plain')
-      .send(`Upgrade Required: deze cirkel draait proto ${KLONKT_PROTO}; jouw Klonkt (proto ${cp}) is te oud.`);
-  }
-  const body = JSON.stringify(buildOutbox(baseUrl(req)), null, 2);
-  res.type('application/activity+json; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=300');
-  res.set('Klonkt-Signature', `ed25519=${signBody(body)}`);
-  res.send(body);
-});
-
-export default router;
