Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/middleware/render.js	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -125,5 +125,5 @@
     // Cirkel = the artists you feature (auto-boost). Shown when AP is on and you
     // auto-boost ≥1 account, or (legacy) on a circle-tenancy site.
-    hasCirkel: !!(_site && ((apEnabled() && ActivityPubService.autoBoostCount(_site.slug) > 0) || (res.locals.tenancy || 'solo') === 'circle')),
+    hasCirkel: !!(_site && apEnabled() && ActivityPubService.autoBoostCount(_site.slug) > 0),
     isViewer: _isViewer,
     canMutate: !_isViewer,
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;
Index: src/server.js
===================================================================
--- src/server.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/server.js	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -42,7 +42,4 @@
 import postsRoutes from './routes/posts.js';
 import langRoutes from './routes/lang.js';
-import federationRoutes from './routes/federation.js';
-import { startCircleSyncLoop } from './services/CircleService.js';
-import adminCircleRoutes from './routes/admin-circle.js';
 import adminUpdatesRoutes from './routes/admin-updates.js';
 import adminPatreonRoutes from './routes/admin-patreon.js';
@@ -198,7 +195,4 @@
 // Klonkt is PWA-only; assetlinks.json is no longer served.
 
-// Circles: periodic background sync of remote instances (no-op unless tenancy='circle').
-startCircleSyncLoop();
-
 // Bundle HTMX: copy from node_modules into our own assets dir so we can serve
 // it locally (no third-party CDN). Idempotent — only copies if size differs.
@@ -217,8 +211,4 @@
   }
 })();
-
-// Circle federation: public, site-agnostic endpoints (/.klonkt/*).
-// Before resolveSite/theme — they don't need a site context.
-app.use(federationRoutes);
 
 // ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug).
@@ -304,5 +294,4 @@
 app.use('/admin/settings', adminSettingsRoutes);
 app.use('/admin/seo', adminSeoRoutes);
-app.use('/admin/circle', adminCircleRoutes);
 app.use('/admin/updates', adminUpdatesRoutes);
 app.use('/admin/patreon', adminPatreonRoutes);
Index: src/services/CircleFederation.js
===================================================================
--- src/services/CircleFederation.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,184 +1,0 @@
-// CircleFederation.js — publication side of "Circles" (v1).
-//
-// Publishes this instance as an ActivityStreams actor with an Ed25519 key,
-// plus an outbox of public posts. The outbox is signed so that consumers
-// (other Klonkt instances) can verify the origin.
-//
-// v1 = PUBLISH + sign only. Pulling/verifying remote circles
-// (CircleService.sync) comes in a later step. See docs/cirkels-v1-spec.md.
-//
-// (The idea of sticking neatly to existing standards was whispered to us by
-//  a certain Bart. Who he is, where he came from — nobody knows for sure.
-//  He appeared, spoke of ActivityStreams, and was gone.)
-
-import crypto from 'crypto';
-import db from '../config/database.js';
-import { getSetting, setSetting } from './SettingsService.js';
-
-// ── Protocol version (federation) ────────────────────────────
-// KLONKT_PROTO is embedded IN the signed input (see signingInput): an instance
-// not running this proto CANNOT verify our signed outbox, and we cannot verify
-// theirs. Staying current is therefore not a polite check you can patch away,
-// but cryptographically enforced — the only way to participate is to run the
-// same proto (= apply the update). Bump KLONKT_PROTO for every release that
-// touches federation/security, and attach a security fix to each bump →
-// outdated = excluded + insecure.
-// MIN_PROTO = the lowest proto we still federate with.
-export const KLONKT_PROTO = 2;
-export const MIN_PROTO = 2;
-
-function signingInput(proto, body) {
-  return `klonkt/proto/${proto}\n${body}`;
-}
-
-// ── Key management ────────────────────────────────────────────
-// Per-instance Ed25519 keypair, generated once and stored in app_settings.
-// Private = PKCS8 PEM (never served). Public = SPKI DER base64
-// (published in the actor; round-tripped via createPublicKey).
-function getKeys() {
-  let priv = getSetting('circle_privkey_pem', null);
-  let pub = getSetting('circle_pubkey_der_b64', null);
-  if (!priv || !pub) {
-    const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
-    priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
-    pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
-    setSetting('circle_privkey_pem', priv);
-    setSetting('circle_pubkey_der_b64', pub);
-  }
-  return { priv, pub };
-}
-
-export function getPublicKeyB64() {
-  return getKeys().pub;
-}
-
-/** Signs a body string, bound to the protocol version (Ed25519). */
-export function signBody(rawString, proto = KLONKT_PROTO) {
-  const key = crypto.createPrivateKey(getKeys().priv);
-  return crypto.sign(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key).toString('base64');
-}
-
-/** Verifies a body against an SPKI-DER-base64 public key for the given proto.
- *  A proto mismatch = a signing-input mismatch = invalid signature. */
-export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
-  try {
-    const key = crypto.createPublicKey({
-      key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
-    });
-    return crypto.verify(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key, Buffer.from(sigB64, 'base64'));
-  } catch {
-    return false;
-  }
-}
-
-// ── Helpers ───────────────────────────────────────────────────
-function primarySite() {
-  // Solo: the primary/owner site (oldest) — same choice as resolveSite.
-  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
-}
-
-function stripHtml(s) {
-  return String(s || '')
-    .replace(/<[^>]+>/g, ' ')
-    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
-    .replace(/\s+/g, ' ')
-    .trim();
-}
-
-// Tags column (JSON array or comma-separated) -> clean string array.
-function parseTags(raw) {
-  if (!raw) return [];
-  if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
-  try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ }
-  return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
-}
-
-function iso(d) {
-  const t = d ? new Date(d) : new Date();
-  return isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
-}
-
-function abs(base, u) {
-  if (!u) return u;
-  return /^https?:\/\//.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
-}
-
-// allow_circle: a site may appear in other instances' circles. v1 ties this
-// to is_public (a separate explicit flag follows in the admin UX step).
-function allowsCircle(site) {
-  return !!site && site.is_public !== 0 && site.allow_circle !== 0;
-}
-
-// ── Actor ─────────────────────────────────────────────────────
-export function buildActor(base) {
-  const site = primarySite();
-  const id = `${base}/.klonkt/actor.json`;
-  const icon = site && (site.profile_photo || site.og_image_default);
-  return {
-    '@context': ['https://www.w3.org/ns/activitystreams', 'https://schema.org/'],
-    type: 'Person',
-    id,
-    name: site ? (site.profile_name || site.title || 'Klonkt') : 'Klonkt',
-    summary: site ? (site.profile_bio || site.tagline || site.description || '') : '',
-    url: `${base}/`,
-    ...(icon ? { icon: { type: 'Image', url: abs(base, icon) } } : {}),
-    outbox: `${base}/.klonkt/outbox.json`,
-    publicKey: {
-      id: `${id}#key`,
-      owner: id,
-      algorithm: 'ed25519',
-      publicKeyBase64: getPublicKeyB64(),
-    },
-    klonkt: { version: 1, proto: KLONKT_PROTO, allowCircle: allowsCircle(site) },
-  };
-}
-
-// ── Outbox ────────────────────────────────────────────────────
-export function buildOutbox(base) {
-  const site = primarySite();
-  const id = `${base}/.klonkt/outbox.json`;
-  const empty = {
-    '@context': 'https://www.w3.org/ns/activitystreams',
-    type: 'OrderedCollection', id, totalItems: 0, orderedItems: [], klonkt: { proto: KLONKT_PROTO },
-  };
-  if (!allowsCircle(site)) return empty;
-
-  const rows = db.prepare(`
-    SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type, tags
-    FROM posts
-    WHERE site_id = ? AND status = 'published'
-      AND (origin_server = 'local' OR origin_server IS NULL)
-    ORDER BY COALESCE(published_at, created_at) DESC
-    LIMIT 50
-  `).all(site.id);
-
-  const orderedItems = rows.map((p) => {
-    const url = `${base}/${p.slug}`;
-    const published = iso(p.published_at || p.created_at);
-    const summary = (p.excerpt || stripHtml(p.content)).slice(0, 500);
-    const tags = parseTags(p.tags).slice(0, 12);
-    return {
-      type: 'Create',
-      id: `${url}#create`,
-      published,
-      actor: `${base}/.klonkt/actor.json`,
-      object: {
-        type: p.type === 'audio' ? 'Audio' : 'Article',
-        id: url,
-        name: p.title || '(zonder titel)',
-        summary,
-        url,
-        published,
-        ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
-        // ActivityStreams: tags as Hashtag objects (href points to the source tag page).
-        ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
-      },
-    };
-  });
-
-  return {
-    '@context': 'https://www.w3.org/ns/activitystreams',
-    type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
-    klonkt: { proto: KLONKT_PROTO },
-  };
-}
Index: src/services/CircleService.js
===================================================================
--- src/services/CircleService.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,230 +1,0 @@
-// CircleService.js — pull side of Circles (v1).
-//
-// Per circle_link: fetches the remote actor + outbox, verifies the Ed25519
-// signature, sanitizes, and caches public posts in remote_actors/remote_posts.
-// READ ONLY from remote; never write. See docs/cirkels-v1-spec.md §5b.
-
-import db from '../config/database.js';
-import { verifyBody, KLONKT_PROTO, MIN_PROTO } from './CircleFederation.js';
-import { getTenancy } from './SettingsService.js';
-
-const FETCH_TIMEOUT_MS = 10000;
-const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
-const MAX_ITEMS = 50;
-
-function stripHtml(s) {
-  return String(s || '')
-    .replace(/<[^>]+>/g, ' ')
-    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
-    .replace(/\s+/g, ' ')
-    .trim();
-}
-function iso(d) {
-  const t = d ? new Date(d) : null;
-  return t && !isNaN(t.getTime()) ? t.toISOString() : null;
-}
-function originOf(u) {
-  try { return new URL(u).origin; } catch { return null; }
-}
-function baseOf(remoteUrl) {
-  return String(remoteUrl).replace(/\/+$/, '');
-}
-
-// AS Hashtag array -> comma-separated tag names (without #), sanitized.
-function extractTags(tag) {
-  if (!Array.isArray(tag)) return null;
-  const names = tag
-    .map((t) => String((t && t.name) || '').replace(/^#/, '').trim())
-    .filter(Boolean)
-    .slice(0, 12);
-  return names.length ? names.join(', ') : null;
-}
-
-// Mark a source as outside the circle with a readable reason (no silent failure).
-// Separate 'outdated' status so the admin UI can show a clean "update required"
-// notice instead of a generic error.
-function markOutdated(link, msg) {
-  // Remove cached posts from this source: we can no longer verify or refresh
-  // them (proto mismatch), so they no longer belong in the circle feed.
-  if (link.remote_actor_id) {
-    try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {}
-  }
-  db.prepare("UPDATE circle_links SET status='outdated', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
-    .run(String(msg).slice(0, 300), link.id);
-  return { ok: false, outdated: true, link: link.remote_url, error: msg };
-}
-
-// Robust, defensive fetch: https only, timeout, body cap, redirect follow.
-async function fetchText(url) {
-  if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
-  const ac = new AbortController();
-  const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
-  try {
-    const res = await fetch(url, {
-      signal: ac.signal,
-      redirect: 'follow',
-      headers: {
-        Accept: 'application/activity+json, application/json',
-        // Tell the publisher our proto → they can reject us with 426 if we are too old.
-        'Klonkt-Proto': String(KLONKT_PROTO),
-      },
-    });
-    if (!res.ok) throw new Error(`HTTP ${res.status}`);
-    const buf = Buffer.from(await res.arrayBuffer());
-    if (buf.length > MAX_BODY_BYTES) throw new Error('body too large');
-    return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
-  } finally {
-    clearTimeout(timer);
-  }
-}
-
-// Lazy prepares — tables only exist after initializeDatabase(); this module is
-// imported before that call, so do not prepare at module level.
-let _stmts = null;
-function stmts() {
-  if (_stmts) return _stmts;
-  _stmts = {
-    upsertActor: db.prepare(`
-      INSERT INTO remote_actors (id, url, name, summary, avatar, public_key, fetched_at)
-      VALUES (@id, @url, @name, @summary, @avatar, @public_key, CURRENT_TIMESTAMP)
-      ON CONFLICT(id) DO UPDATE SET
-        url=excluded.url, name=excluded.name, summary=excluded.summary,
-        avatar=excluded.avatar, public_key=excluded.public_key, fetched_at=CURRENT_TIMESTAMP
-    `),
-    upsertPost: db.prepare(`
-      INSERT INTO remote_posts (id, actor_id, published, title, summary, url, media_json, tags, raw_json, fetched_at)
-      VALUES (@id, @actor_id, @published, @title, @summary, @url, @media_json, @tags, @raw_json, CURRENT_TIMESTAMP)
-      ON CONFLICT(id) DO UPDATE SET
-        published=excluded.published, title=excluded.title, summary=excluded.summary,
-        url=excluded.url, media_json=excluded.media_json, tags=excluded.tags,
-        raw_json=excluded.raw_json, fetched_at=CURRENT_TIMESTAMP
-    `),
-  };
-  return _stmts;
-}
-
-export async function syncOne(link) {
-  const base = baseOf(link.remote_url);
-
-  // 1. Fetch + validate actor
-  const actorUrl = `${base}/.klonkt/actor.json`;
-  const a = await fetchText(actorUrl);
-  let actor;
-  try { actor = JSON.parse(a.text); } catch { throw new Error('actor: ongeldige JSON'); }
-  const actorId = actor.id;
-  const pubKey = actor.publicKey && actor.publicKey.publicKeyBase64;
-  if (!actorId || !pubKey) throw new Error('actor mist id/publicKey');
-  if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
-
-  // Protocol version gate. The proto is also embedded in the outbox signing
-  // input, so lying in the (unsigned) actor does not help: a real mismatch
-  // will still fail verification later. This check is mainly for a CLEAR
-  // message + exclusion without silent failure.
-  const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
-  if (remoteProto > KLONKT_PROTO) {
-    return markOutdated(link,
-      `Deze site draait een nieuwere Klonkt (proto ${remoteProto}); jouw instance is proto ${KLONKT_PROTO}. Werk je eigen Klonkt bij om te blijven federeren.`);
-  }
-  if (remoteProto < MIN_PROTO) {
-    return markOutdated(link,
-      `Draait een oudere Klonkt (proto ${remoteProto}; minimaal ${MIN_PROTO} vereist). Vraag ze te updaten.`);
-  }
-
-  // TOFU: a key change requires explicit re-confirmation (anti-hijack)
-  const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
-  if (existing && existing.public_key !== pubKey) {
-    throw new Error('publieke sleutel gewijzigd — herbevestiging vereist (TOFU)');
-  }
-
-  stmts().upsertActor.run({
-    id: actorId,
-    url: actor.url || base,
-    name: actor.name || null,
-    summary: actor.summary || null,
-    avatar: (actor.icon && actor.icon.url) || null,
-    public_key: pubKey,
-  });
-
-  // 2. Fetch outbox + verify signature
-  const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
-  const o = await fetchText(outboxUrl);
-  const sigHeader = o.headers.get('klonkt-signature') || '';
-  const sig = (sigHeader.match(/ed25519=(.+)\s*$/) || [])[1];
-  if (!sig || !verifyBody(o.text, sig, pubKey, remoteProto)) {
-    throw new Error('outbox-handtekening ongeldig of ontbreekt');
-  }
-  let outbox;
-  try { outbox = JSON.parse(o.text); } catch { throw new Error('outbox: ongeldige JSON'); }
-  const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
-
-  // 3. Sanitize + cache objects (same origin as actor = anti-impersonation)
-  const actorOrigin = originOf(actorId);
-  const seen = new Set();
-  for (const it of items) {
-    const obj = it && it.object;
-    if (!obj || !obj.id) continue;
-    if (originOf(obj.id) !== actorOrigin) continue;
-    const media = [];
-    if (obj.image && obj.image.url) media.push({ type: 'image', url: obj.image.url });
-    if (Array.isArray(obj.attachment)) {
-      for (const att of obj.attachment) {
-        if (att && att.url) media.push({ type: String(att.type || 'link').toLowerCase(), url: att.url, name: att.name, duration: att.duration });
-      }
-    }
-    stmts().upsertPost.run({
-      id: obj.id,
-      actor_id: actorId,
-      published: iso(obj.published || it.published),
-      title: stripHtml(obj.name).slice(0, 300) || '(zonder titel)',
-      summary: stripHtml(obj.summary || obj.content).slice(0, 1000),
-      url: obj.url || obj.id,
-      media_json: media.length ? JSON.stringify(media) : null,
-      tags: extractTags(obj.tag),
-      raw_json: JSON.stringify(obj).slice(0, 20000),
-    });
-    seen.add(obj.id);
-  }
-
-  // 4. Pruning: remove posts that are no longer in the outbox
-  const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
-  const stale = known.filter((id) => !seen.has(id));
-  if (stale.length) {
-    const del = db.prepare('DELETE FROM remote_posts WHERE id = ?');
-    db.transaction((ids) => ids.forEach((id) => del.run(id)))(stale);
-  }
-
-  // Automatically adopt the name from the remote actor (no manual entry needed).
-  // COALESCE: if the actor has no name, any existing label is preserved.
-  db.prepare(
-    "UPDATE circle_links SET remote_actor_id=?, label=COALESCE(?, label), last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
-  ).run(actorId, actor.name || null, link.id);
-
-  return { ok: true, actorId, items: seen.size, pruned: stale.length };
-}
-
-export async function sync() {
-  if (getTenancy() !== 'circle') return { skipped: 'tenancy != circle' };
-  const links = db.prepare("SELECT * FROM circle_links WHERE status != 'paused'").all();
-  const results = [];
-  for (const link of links) {
-    try {
-      results.push(await syncOne(link));
-    } 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);
-      results.push({ ok: false, link: link.remote_url, error: msg });
-    }
-  }
-  return { synced: results.length, results };
-}
-
-let _timer = null;
-/** Periodic background sync (gated on tenancy='circle' inside sync()). */
-export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
-  if (_timer) return;
-  const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
-  setTimeout(run, 30 * 1000); // short delay after boot
-  _timer = setInterval(run, intervalMs);
-  if (_timer.unref) _timer.unref();
-}
Index: src/services/SettingsService.js
===================================================================
--- src/services/SettingsService.js	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/services/SettingsService.js	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -40,12 +40,11 @@
 
 export function getTenancy() {
-  const v = getSetting('tenancy', 'solo');
-  // 'hub' is removed → coerce legacy values to 'solo'.
-  return v === 'circle' ? 'circle' : 'solo';
+  // Tenancy is retired: 'hub' and 'circle' were both removed. Every site is
+  // 'solo'. Cirkels are now an ActivityPub feature (auto-boost), not a mode.
+  return 'solo';
 }
 
-export function setTenancy(mode) {
-  const m = mode === 'circle' ? 'circle' : 'solo';
-  setSetting('tenancy', m);
+export function setTenancy() {
+  setSetting('tenancy', 'solo');
 }
 
Index: src/views/pages/admin-circle.ejs
===================================================================
--- src/views/pages/admin-circle.ejs	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,133 +1,0 @@
-<div class="container admin-page">
-  <h1><%= t('acir.title') %></h1>
-  <p><a href="/admin/settings" class="btn">&larr; <%= t('acir.back_settings') %></a></p>
-
-  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <% if (tenancy !== 'circle') { %>
-    <section class="set-card">
-      <p class="set-help"><%= t('acir.mode_off_1') %> <strong><%= t('acir.circles') %></strong>. <%= t('acir.mode_off_2') %>
-        <a href="/admin/settings"><%= t('acir.settings') %></a> <%= t('acir.mode_off_3') %> <code>/cirkel</code>.
-        <%= t('acir.mode_off_4') %></p>
-    </section>
-  <% } %>
-
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('acir.visibility_title') %></h2>
-    <p class="set-help"><%= t('acir.visibility_help_1') %> <strong><%= t('acir.all_public') %></strong> <%= t('acir.visibility_help_2') %>
-      (<code>/.klonkt/outbox.json</code>). <%= t('acir.visibility_help_3') %></p>
-    <form method="post" action="/admin/circle/allow" class="set-form">
-      <label class="set-opt" style="align-items:center">
-        <input type="checkbox" name="allow_circle" value="on" <%= allowCircle ? 'checked' : '' %>>
-        <span><strong><%= t('acir.show_in_circles') %></strong></span>
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('acir.save') %></button>
-    </form>
-  </section>
-
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('acir.add_title') %></h2>
-    <p class="set-help"><%= t('acir.add_help') %></p>
-    <form method="post" action="/admin/circle/add" class="set-form">
-      <label class="set-field">
-        <span><%= t('acir.url') %></span>
-        <input type="text" name="remote_url" inputmode="url" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="artiest.klonkt.com" required>
-      </label>
-      <p class="set-help" style="margin:.2rem 0 .6rem"><%= t('acir.name_auto') %></p>
-      <button type="submit" class="btn btn-primary"><%= t('acir.add') %></button>
-    </form>
-  </section>
-
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('acir.in_circle', { n: links.length }) %></h2>
-    <% if (!links.length) { %>
-      <p class="set-help"><%= t('acir.no_sources') %></p>
-    <% } else { %>
-      <form method="post" action="/admin/circle/sync-all" style="margin:0 0 .9rem">
-        <button type="submit" class="btn">&#8635; <%= t('acir.sync_all') %></button>
-      </form>
-      <%
-        var _cActive = t('acir.st_active');
-        var _cPosts = t('acir.posts');
-        var _cLast = t('acir.last');
-        var _cMismatch = t('acir.st_mismatch');
-        var _cMismatchReason = t('acir.mismatch_reason');
-        var _cError = t('acir.st_error');
-        var _cPaused = t('acir.st_paused');
-        var _cRefresh = t('acir.refresh');
-        var _cRemove = t('acir.remove');
-        var _cRemoveConfirm = t('acir.remove_confirm');
-      %>
-      <ul class="circ-list">
-        <% links.forEach(function(l){ %>
-          <li class="circ-item">
-            <div class="circ-main">
-              <strong><%= l.label || l.remote_url %></strong>
-              <small><%= l.remote_url %></small>
-              <div class="circ-status">
-                <% if (l.status === 'active') { %>
-                  <span class="circ-badge ok">&#10003; <%= _cActive %></span>
-                  <span class="circ-meta"><%= counts[l.id] || 0 %> <%= _cPosts %><% if (l.last_synced) { %> &middot; <%= _cLast %>: <%= l.last_synced %><% } %></span>
-                <% } else if (l.status === 'outdated') { %>
-                  <span class="circ-badge err">&#8635; <%= _cMismatch %></span>
-                  <div class="circ-reason"><%= l.last_error || _cMismatchReason %></div>
-                <% } else if (l.status === 'error') { %>
-                  <span class="circ-badge err">&#9888; <%= _cError %></span>
-                  <% if (l.last_error) { %><div class="circ-reason"><%= l.last_error %></div><% } %>
-                <% } else if (l.status === 'paused') { %>
-                  <span class="circ-badge">&#9208; <%= _cPaused %></span>
-                <% } else { %>
-                  <%# Unknown status: show it raw + any reason instead of silently showing "paused". %>
-                  <span class="circ-badge"><%= l.status %></span>
-                  <% if (l.last_error) { %><div class="circ-reason"><%= l.last_error %></div><% } %>
-                <% } %>
-              </div>
-            </div>
-            <div class="circ-actions">
-              <form method="post" action="/admin/circle/<%= l.id %>/sync" style="display:inline">
-                <button type="submit" class="btn"><%= _cRefresh %></button>
-              </form>
-              <form method="post" action="/admin/circle/<%= l.id %>/remove" style="display:inline"
-                    onsubmit="return confirm('<%= _cRemoveConfirm %>')">
-                <button type="submit" class="btn btn-danger"><%= _cRemove %></button>
-              </form>
-            </div>
-          </li>
-        <% }); %>
-      </ul>
-    <% } %>
-  </section>
-</div>
-
-<style>
-.admin-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
-.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 .25rem; }
-.set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
-.set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 .5rem; }
-.set-help { color: var(--ink-muted); font-size: .9rem; margin: 0 0 1rem; }
-.set-form { display: flex; flex-direction: column; gap: 1rem; }
-.set-opt { display: flex; gap: .75rem; align-items: flex-start; padding: .85rem 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); }
-.set-field { display: flex; flex-direction: column; gap: .3rem; }
-.set-field > span { font-size: .8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
-.set-field input { width: 100%; box-sizing: border-box; padding: .55rem .7rem; border: 1px solid var(--rule); border-radius: 6px; background: var(--paper); color: var(--ink); font: inherit; }
-.set-form .btn, .circ-actions .btn { align-self: flex-start; }
-.circ-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
-.circ-item { display: flex; justify-content: space-between; gap: 1rem; align-items: center; padding: .75rem 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); flex-wrap: wrap; }
-.circ-main { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
-.circ-main small { color: var(--ink-muted); font-size: .78rem; word-break: break-all; }
-.circ-actions { display: flex; gap: .4rem; flex-shrink: 0; }
-.circ-badge { font-size: .72rem; font-weight: 700; padding: .1rem .45rem; border-radius: 20px; background: var(--rule); white-space: nowrap; }
-.circ-badge.ok { background: #d1fae5; color: #065f46; }
-.circ-badge.err { background: #fee2e2; color: #991b1b; }
-.circ-status { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .5rem; margin-top: .25rem; font-size: .78rem; color: var(--ink-muted); }
-.circ-meta { color: var(--ink-muted); }
-/* Reason on its own line below the badge; normal word breaking (no break-all
-   that cuts whole sentences mid-word — that is applied to the URL small, not here). */
-.circ-reason { flex-basis: 100%; line-height: 1.4; color: var(--ink-muted); word-break: normal; overflow-wrap: anywhere; }
-.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
-.btn-danger:hover { background: #b91c1c; border-color: #b91c1c; color: #fff; }
-.alert { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
-.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
-.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
-</style>
Index: src/views/pages/admin-settings.ejs
===================================================================
--- src/views/pages/admin-settings.ejs	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/views/pages/admin-settings.ejs	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -5,29 +5,4 @@
   <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
   <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <section class="set-card">
-    <h2><%= t('aset.mode') %></h2>
-    <p class="set-help">
-      <%= t('aset.mode_help') %>
-    </p>
-
-    <form method="post" action="/admin/settings" class="set-form">
-      <label class="set-opt">
-        <input type="radio" name="tenancy" value="solo" <%= tenancy === 'solo' ? 'checked' : '' %>>
-        <span>
-          <strong><%= t('aset.solo') %></strong> — <%= t('aset.solo_title') %>
-          <small><%= t('aset.solo_desc') %></small>
-        </span>
-      </label>
-      <label class="set-opt">
-        <input type="radio" name="tenancy" value="circle" <%= tenancy === 'circle' ? 'checked' : '' %>>
-        <span>
-          <strong><%= t('aset.circle') %></strong> &mdash; <%= t('aset.circle_title') %>
-          <small><%= t('aset.circle_desc') %></small>
-        </span>
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('aset.save') %></button>
-    </form>
-  </section>
 
   <%# ── Default language for visitors ── %>
@@ -70,15 +45,4 @@
 
   <%# Mode-specific config — Circle settings below the mode card. (Hub removed.) %>
-  <% if (tenancy === 'circle') { %>
-  <section class="set-card set-card--mode" style="margin-top:1rem">
-    <h2>🔗 <%= t('aset.your_circle') %></h2>
-    <p class="set-help">
-      <%= t('aset.your_circle_help') %>
-    </p>
-    <div class="set-actions">
-      <a href="/admin/circle" class="btn btn-primary"><%= t('aset.manage_circle') %> &rarr;</a>
-    </div>
-  </section>
-  <% } %>
 
   <%# Premium (Patreon) — only visible when the self-hoster enables the premium layer
Index: src/views/pages/admin.ejs
===================================================================
--- src/views/pages/admin.ejs	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/views/pages/admin.ejs	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -43,7 +43,4 @@
         <a href="/admin/sites/new" class="btn"><%= t('admin.b_makesite') %></a>
       <% } %>
-      <% if (tenancy === 'circle') { %>
-        <a href="/admin/circle" class="btn"><%= t('admin.b_circle') %></a>
-      <% } %>
       <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>
       <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>
Index: src/views/pages/changelog.ejs
===================================================================
--- src/views/pages/changelog.ejs	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ src/views/pages/changelog.ejs	(revision 429d3b0e97f3514be589f2982109dd369e53a631)
@@ -4,6 +4,4 @@
   <p class="cl-meta">
     <%= t('chlog.app_version') %> <strong>v<%= appVersion %></strong>
-    <span class="cl-sep">·</span>
-    <%= t('chlog.fed_proto') %> <strong><%= proto %></strong>
     <% if (typeof user !== 'undefined' && user && user.role === 'god') { %>
       <span class="cl-sep">·</span> <a href="/admin/updates"><%= t('chlog.manage_updates') %></a>
Index: src/views/pages/circle-post.ejs
===================================================================
--- src/views/pages/circle-post.ejs	(revision 46f3dd6f843397dd4da3f37539315aedb56c20f7)
+++ 	(revision )
@@ -1,61 +1,0 @@
-<article class="container cirkel-post">
-  <p class="cirkel-post-back">
-    <a href="/cirkel" hx-get="/cirkel?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-       hx-push-url="/cirkel" hx-indicator="#pcms-loading">&larr; <%= t('cpost.back') %></a>
-  </p>
-
-  <header class="cirkel-post-head">
-    <div class="cirkel-post-src">
-      <% if (post.sourceAvatar) { %><img class="cirkel-post-avatar" src="<%= post.sourceAvatar %>" alt=""><% } %>
-      <span><%= t('cpost.via') %>
-        <% if (post.sourceUrl) { %><a href="<%= post.sourceUrl %>" target="_blank" rel="noopener"><%= post.sourceName %></a>
-        <% } else { %><%= post.sourceName %><% } %>
-      </span>
-      <% if (post.published) { %><span class="cirkel-post-date">&middot; <%= post.published.slice(0, 10) %></span><% } %>
-    </div>
-    <h1 class="cirkel-post-title"><%= post.title %></h1>
-  </header>
-
-  <% if (post.image) { %>
-    <img class="cirkel-post-cover" src="<%= post.image %>" alt="" loading="lazy">
-  <% } %>
-
-  <div class="cirkel-post-body"><%= post.body %></div>
-
-  <% if (post.tags && post.tags.length) { %>
-    <div class="cirkel-post-tags">
-      <% post.tags.forEach(function(t){ %>
-        <% if (post.sourceTagBase) { %>
-          <a class="cirkel-tag" href="<%= post.sourceTagBase %>/tag/<%= encodeURIComponent(t) %>" target="_blank" rel="noopener">#<%= t %></a>
-        <% } else { %>
-          <span class="cirkel-tag">#<%= t %></span>
-        <% } %>
-      <% }); %>
-    </div>
-  <% } %>
-
-  <% if (post.originalUrl) { %>
-    <p class="cirkel-post-readmore">
-      <a href="<%= post.originalUrl %>" target="_blank" rel="noopener"><%= t('cpost.read_more', { source: post.sourceName }) %> &rarr;</a>
-    </p>
-  <% } %>
-</article>
-
-<style>
-.cirkel-post { max-width: 720px; margin: 2rem auto; }
-.cirkel-post-back a { color: var(--ink-muted); text-decoration: none; font-size: .85rem; }
-.cirkel-post-back a:hover { color: var(--accent); }
-.cirkel-post-head { margin: 1rem 0 1.25rem; }
-.cirkel-post-src { display: flex; align-items: center; gap: .45rem; font-size: .85rem; color: var(--ink-muted); margin-bottom: .5rem; }
-.cirkel-post-src a { color: var(--ink-muted); }
-.cirkel-post-src a:hover { color: var(--accent); }
-.cirkel-post-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; }
-.cirkel-post-title { font-family: var(--font-display, serif); font-size: 1.9rem; line-height: 1.15; margin: 0; }
-.cirkel-post-cover { width: 100%; border-radius: var(--radius); margin: 0 0 1.5rem; display: block; }
-.cirkel-post-body { font-size: 1.05rem; line-height: 1.7; white-space: pre-wrap; color: var(--ink); }
-.cirkel-post-tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: 1.25rem; }
-.cirkel-tag { font-size: .8rem; color: var(--ink-muted); background: var(--paper-2); border: 1px solid var(--rule); border-radius: 999px; padding: .15rem .6rem; text-decoration: none; }
-.cirkel-tag:hover { border-color: var(--accent); color: var(--accent); }
-.cirkel-post-readmore { margin-top: 2rem; padding-top: 1.25rem; border-top: 1px solid var(--rule); }
-.cirkel-post-readmore a { color: var(--accent); text-decoration: none; font-weight: 600; }
-</style>
