Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 26c5f71eb4c92718f426c99fffd1b73b3d5a9c9a)
+++ src/routes/activitypub.js	(revision 679924e6d95aa4c43511590dbb0e4123ea6596cb)
@@ -20,4 +20,5 @@
 import OAuth from '../services/OAuthService.js';
 import * as Guardianship from '../services/guardianship/index.js';
+import { getPrimarySite } from '../middleware/site.js';
 import multer from 'multer';
 import path from 'path';
@@ -43,5 +44,10 @@
 const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
 const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
-const primarySlug = () => { const r = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get(); return r && r.slug; };
+// The primary site, via the one source of truth in middleware/site.js — which
+// falls back to the oldest site when nothing carries the is_primary flag. This
+// route used to keep its own is_primary-only copy, so a fresh instance whose
+// site was never flagged served its HTML at / (that resolver falls back) while
+// WebFinger and the actor route insisted it had no primary at all.
+const primarySlug = () => { const s = getPrimarySite(); return s && s.slug; };
 // A hostname as a human types it and as DNS stores it are the same host:
 // `🩵.is.wildenvrij.nl` IS `xn--zz9h.is.wildenvrij.nl`. WHATWG URL does the IDNA,
Index: test/webfinger-bare-host.test.js
===================================================================
--- test/webfinger-bare-host.test.js	(revision 26c5f71eb4c92718f426c99fffd1b73b3d5a9c9a)
+++ test/webfinger-bare-host.test.js	(revision 679924e6d95aa4c43511590dbb0e4123ea6596cb)
@@ -28,6 +28,10 @@
 // `kid` is the primary site; `oma` is a second public site that must NOT be
 // what a bare host resolves to.
-db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
-db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s2', 'oma', 'oma', 'u1');
+// Explicit created_at: getPrimarySite() falls back to the OLDEST site, and two
+// rows inserted in the same second would make that order a coin flip.
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,1,?)')
+  .run('s1', 'kid', 'kid', 'u1', '2026-01-01 00:00:00');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,0,?)')
+  .run('s2', 'oma', 'oma', 'u1', '2026-06-01 00:00:00');
 
 const app = express();
@@ -86,4 +90,20 @@
 });
 
+test('a bare host resolves even when no site carries the primary flag', async () => {
+  // This is the state a fresh instance is actually in: is_primary defaults to 0
+  // and the backfill only runs when the column is first added, so a site created
+  // afterwards leaves the instance with no primary at all. The HTML side coped
+  // (getPrimarySite falls back to the oldest) while this route kept its own
+  // is_primary-only lookup — so / served the site and WebFinger said 404.
+  db.prepare('UPDATE sites SET is_primary = 0').run();
+  try {
+    const { status, body } = await finger('acct:test.example@test.example');
+    assert.equal(status, 200, 'an unflagged instance is still discoverable');
+    assert.equal(actorOf(body), 'https://test.example/ap/users/kid', 'falls back to the oldest site');
+  } finally {
+    db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run('s1');
+  }
+});
+
 test('an unknown user is still a 404', async () => {
   // The fallback must not turn every miss into the primary actor, or a typo
