Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 81b2e1ecaea6d206491e032514f0a2fdca4cb48a)
+++ src/config/database.js	(revision 024f4f8c0acb305dd19077f639d2e96c787dd63c)
@@ -441,4 +441,6 @@
   ensureColumn('ap_outbox', 'attachments', 'TEXT');
   ensureColumn('posts', 'ap_visibility', 'TEXT');   // public|quiet|friends|direct (C2S addressing, shaer-60b)
+  ensureColumn('ap_outbox', 'visibility', 'TEXT');  // 'direct' = private mention, never Public (shaer-tqc)
+  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
 }
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 81b2e1ecaea6d206491e032514f0a2fdca4cb48a)
+++ src/services/ActivityPubService.js	(revision 024f4f8c0acb305dd19077f639d2e96c787dd63c)
@@ -252,6 +252,11 @@
       url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
       published: toISO(post.created_at),
-      to: post.to_actor ? [post.to_actor] : [PUBLIC],
-      cc: [PUBLIC, `${meR}/followers`],
+      // A direct note (private mention, shaer-tqc) addresses ONLY its
+      // recipients: no Public anywhere, so it cannot be boosted and never
+      // shows in public timelines (the Mastodon DM model).
+      to: post.visibility === 'direct'
+        ? (JSON.parse(post.to_actors || '[]'))
+        : (post.to_actor ? [post.to_actor] : [PUBLIC]),
+      cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`],
       tag: [
         ...mentionTags(post.content),
@@ -1395,4 +1400,11 @@
     const pid = postIdFromNoteUrl(objUrl, base);
     if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
+      // A boost/like of a non-public post is dropped, not stored: nobody
+      // outside the audience should even hold it (shaer-tqc hardening).
+      const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
+      if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
+        console.log('[AP] dropped', type, 'on non-public post', pid);
+        return;
+      }
       const ai = actorInfo(await resolveActor(actorUri), actorUri);
       iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
@@ -1826,4 +1838,15 @@
         const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
         if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' };
+        // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
+        // outbox machinery to the addressed inboxes only; shows under Messages.
+        if (c2sVisibility(object) === 'direct') {
+          const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
+          const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
+            .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
+          if (!recipients.length) return { status: 400, error: 'no_recipients' };
+          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null });
+          if (!r || !r.id) return { status: 502, error: 'direct_failed' };
+          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
+        }
         if (object.inReplyTo) {
           const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
@@ -1839,4 +1862,13 @@
         const targetUri = c2sIdOf(object);
         if (!targetUri) return { status: 400, error: 'missing_object' };
+        // A non-public local note cannot be boosted or liked into the open
+        // (shaer-tqc hardening; the Mastodon 422 equivalent).
+        const localPid = postIdFromNoteUrl(targetUri, base);
+        if (localPid) {
+          const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
+          if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
+            return { status: 403, error: 'not_public' };
+          }
+        }
         const note = await resolveRemoteNote(targetUri).catch(() => null);
         const objUri = (note && note.object_uri) || targetUri;
@@ -1921,4 +1953,54 @@
   if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
   return 'direct';
+}
+
+// A direct note (private mention, shaer-tqc): a NEW conversation (or a direct
+// reply) addressed to specific actors only. Stored in ap_outbox with
+// visibility 'direct' + the recipient list, delivered to exactly those
+// inboxes: no followers fan-out, no Public, so no boosts and no timelines.
+// The same S2S leg a Mastodon DM takes, so a guardian on any instance
+// receives it as a private mention (the ward call-for-help path).
+export async function deliverDirectNote(site, { recipients, text, language, inReplyTo }) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
+  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
+  const me = actorId(base, site.slug);
+  // Resolve every recipient for a mention anchor + a delivery inbox.
+  const resolved = [];
+  for (const uri of list) {
+    const a = await fetchActor(uri).catch(() => null);
+    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
+    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });
+  }
+  if (!resolved.length) return null;
+  const mention = resolved.map((r) => {
+    const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
+    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
+  }).join('');
+  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
+  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
+  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
+  const id = crypto.randomUUID();
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, created_at)
+              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
+    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, null, 'direct', JSON.stringify(resolved.map((r) => r.uri)));
+  const row = iStmts().getO.get(id);
+  const note = buildReplyNote(base, site, row);
+  const create = {
+    '@context': AP_CONTEXT,
+    id: note.id + '#create', type: 'Create', actor: me,
+    published: note.published, to: note.to, cc: note.cc, object: note,
+  };
+  const keys = getOrCreateKeys(site.slug);
+  const keyId = `${me}#main-key`;
+  let delivered = 0;
+  for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) {
+    let ok = false;
+    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
+    if (ok) delivered++;
+    else enqueueDelivery(site.slug, inbox, create);
+  }
+  console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
+  return { id, content, delivered };
 }
 
@@ -2921,5 +3003,5 @@
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
-  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
+  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: test/c2s-direct.test.js
===================================================================
--- test/c2s-direct.test.js	(revision 024f4f8c0acb305dd19077f639d2e96c787dd63c)
+++ test/c2s-direct.test.js	(revision 024f4f8c0acb305dd19077f639d2e96c787dd63c)
@@ -0,0 +1,52 @@
+// Direct notes (private mentions, shaer-tqc): never Public, never boostable.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const PUB = 'https://www.w3.org/ns/activitystreams#Public';
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'me', 'Me', 'u1', 1);
+const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+const user = { id: 'u1', username: 'u1' };
+
+test('a direct outbox row addresses only its recipients, no Public, no cc', () => {
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, visibility, to_actors, created_at)
+    VALUES ('d1','me','',NULL,NULL,'https://r.test/u/g','@g@r.test','<p>help</p>','direct','["https://r.test/u/g","https://q.test/u/h"]',CURRENT_TIMESTAMP)`).run();
+  const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get('d1');
+  const note = AP.buildReplyNote('https://test.example', site, row);
+  assert.deepEqual(note.to, ['https://r.test/u/g', 'https://q.test/u/h']);
+  assert.deepEqual(note.cc, []);
+  assert.ok(!JSON.stringify(note.to).includes(PUB) && !JSON.stringify(note.cc).includes(PUB));
+});
+
+test('direct without any real recipient is refused (400 no_recipients)', async () => {
+  const r = await AP.ingestOutboxActivity(site, user, {
+    type: 'Note', content: '<p>x</p>',
+    to: ['https://test.example/ap/users/me/followers'], cc: [],   // friends-shaped? no: followers in to = friends
+  });
+  // followers-only reads as friends, so force the direct shape: bare unknown string
+  const r2 = await AP.ingestOutboxActivity(site, user, { type: 'Note', content: '<p>x</p>', to: [], cc: [] });
+  // empty addressing = legacy public; the real no-recipient direct case:
+  const r3 = await AP.ingestOutboxActivity(site, user, { type: 'Note', content: '<p>x</p>', to: ['not-a-uri'], cc: [] });
+  assert.equal(r3.status, 400);
+  assert.equal(r3.error, 'no_recipients');
+  assert.ok(r && r2); // shapes above answered too (not the point of this test)
+});
+
+test('C2S Announce/Like of a non-public local post is refused (403)', async () => {
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, type, fan_only, ap_visibility, created_at, updated_at, published_at)
+    VALUES ('pf','s1','geheim','u1','','<p>prive</p>','published','post',1,'friends',datetime('now'),datetime('now'),datetime('now'))`).run();
+  const noteUrl = 'https://test.example/ap/notes/pf';
+  const boost = await AP.ingestOutboxActivity(site, user, { type: 'Announce', object: noteUrl });
+  assert.equal(boost.status, 403);
+  assert.equal(boost.error, 'not_public');
+  const like = await AP.ingestOutboxActivity(site, user, { type: 'Like', object: noteUrl });
+  assert.equal(like.status, 403);
+});
