Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 4d64a4b8416a3f76c270c0061988a5b243291498)
+++ src/services/ActivityPubService.js	(revision 984e0c6fb2d1b86cfe7692a1bfcb58441fa8519a)
@@ -98,4 +98,17 @@
   followActor, resolveRemoteActor, unfollowActor,
 };
+// Stap 8 (shaer-drc): de peilingen wonen in ap-polls.js. parsePoll,
+// applyPollToNote en recordPollBallot komen terug voor de inbox, buildNote en
+// de backfill, maar blijven naar buiten toe prive zoals ze waren.
+import {
+  wirePolls,
+  parsePoll, applyPollToNote, recordPollBallot,
+  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
+  voteOnPoll, voteOnRemotePoll,
+} from './ap-polls.js';
+export {
+  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
+  voteOnPoll, voteOnRemotePoll,
+};
 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
 export { AP_CONTEXT, actorId, noteId, guessMediaType };
@@ -1723,129 +1736,4 @@
 }
 
-// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
-// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
-// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
-function parsePoll(o) {
-  if (!o || o.type !== 'Question') return null;
-  const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
-  if (!raw || !raw.length) return null;
-  const options = raw.slice(0, 12).map((opt) => ({
-    name: String((opt && opt.name) || '').slice(0, 300),
-    count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
-  })).filter((x) => x.name);
-  if (!options.length) return null;
-  const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
-  const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
-  return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
-}
-
-// ── Polls WE host (a local post with a poll) ──────────────────────
-// Parse the poll definition stored on our own post (posts.poll_json). Counts are
-// NOT stored here — they're derived from the poll_votes ballots so a re-render always
-// reflects the authoritative tally.
-export function parseOwnPoll(pollJson) {
-  if (!pollJson) return null;
-  let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
-  if (!d || !Array.isArray(d.options)) return null;
-  const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
-  if (options.length < 2) return null;
-  const endTime = d.endTime || null;
-  const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
-  return { multiple: !!d.multiple, options, endTime, closed };
-}
-
-// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
-export function pollTally(postId) {
-  const counts = {}; let voters = 0;
-  try {
-    for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
-    voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
-  } catch { /* table may not exist yet */ }
-  return { counts, voters };
-}
-
-// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
-// Voting is fediverse-only, so this is display-only on the site.
-export function ownPollView(post) {
-  const poll = parseOwnPoll(post && post.poll_json);
-  if (!poll) return null;
-  const { counts, voters } = pollTally(post.id);
-  const total = Object.values(counts).reduce((a, b) => a + b, 0);
-  const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
-  const options = poll.options.map((o) => {
-    const count = counts[o.name] || 0;
-    return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
-  });
-  return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
-}
-
-// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
-// status with either media OR a poll (never both), so a poll federates as content +
-// options with no media attachment. oneOf = single choice, anyOf = multiple.
-function applyPollToNote(note, postId, poll) {
-  const { counts, voters } = pollTally(postId);
-  const opts = poll.options.map((o) => ({
-    type: 'Note',
-    name: o.name,
-    replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
-  }));
-  note.type = 'Question';
-  note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
-  if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
-  // Once closed, Mastodon expects a `closed` timestamp (the effective end).
-  if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
-  note.votersCount = voters;
-  delete note.attachment;   // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
-  // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
-  // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
-  // Deleting it stripped the cover off every boosted poll.
-  return note;
-}
-
-// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
-// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
-// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
-// caller must NOT also store it as a reply), false means "not a poll, fall through".
-function recordPollBallot(postId, actorUri, rawChoice) {
-  const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
-  if (!choice) return { handled: false };
-  let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
-  const poll = post && parseOwnPoll(post.poll_json);
-  if (!poll) return { handled: false };               // not a poll → let the reply logic handle it
-  if (poll.closed) return { handled: true };          // voting closed → drop
-  if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
-  try {
-    // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
-    // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
-    if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
-    db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
-  } catch { return { handled: true }; }
-  schedulePollUpdate(postId);
-  return { handled: true };
-}
-
-// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
-// refresh ~15s out; further votes in that window ride the same pending update (which carries
-// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
-const _pollUpdTimers = new Map();
-function schedulePollUpdate(postId) {
-  if (_pollUpdTimers.has(postId)) return;
-  const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
-  if (t.unref) t.unref();
-  _pollUpdTimers.set(postId, t);
-}
-
-// Push the fresh poll tally (or closed state) to followers as Update(Question).
-export async function deliverPollUpdate(postId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !postId) return;
-  let post, site;
-  try {
-    post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
-    if (!post || !post.poll_json) return;
-    site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-  } catch { return; }
-  if (site) await deliverUpdate(site, post);
-}
 
 // ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
@@ -4972,71 +4860,4 @@
 
 // True if an actor (or its whole domain) is blocked anywhere on this instance.
-// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
-// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
-// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
-// author's Update(Question) refreshes the authoritative totals when it arrives.
-export async function voteOnPoll(site, questionId, choices) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !questionId) return { error: 'config' };
-  let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
-  if (!row || !row.poll_json) return { error: 'not_found' };
-  let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
-  if (poll.closed) return { error: 'closed' };
-  if (poll.voted) return { error: 'already' };
-  const valid = new Set(poll.options.map((o) => o.name));
-  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
-  if (!picks.length) return { error: 'invalid' };
-  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const authorUri = row.author_uri || null;
-  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
-  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
-  if (!inbox) return { error: 'unreachable' };
-  for (const name of chosen) {
-    const nid = `${me}/votes/${Date.now()}-${rid()}`;
-    const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
-    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
-    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
-  }
-  // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
-  poll.voted = poll.multiple ? chosen : chosen[0];
-  for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
-  if (poll.voters != null) poll.voters += 1;
-  try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
-  return { ok: true };
-}
-
-// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
-// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
-// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
-// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
-export async function voteOnRemotePoll(site, questionUrl, choices) {
-  const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
-  const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
-  if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
-  const poll = parsePoll(q);
-  if (!poll) return { error: 'not_found' };
-  if (poll.closed) return { error: 'closed' };
-  const valid = new Set(poll.options.map((o) => o.name));
-  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
-  if (!picks.length) return { error: 'invalid' };
-  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
-  const authorUri = actorUriOf(q.attributedTo);
-  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
-  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
-  if (!inbox) return { error: 'unreachable' };
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  for (const name of chosen) {
-    const nid = `${me}/votes/${Date.now()}-${rid()}`;
-    const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
-    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
-    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
-  }
-  return { ok: true };
-}
 
 // Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
@@ -5351,4 +5172,7 @@
 // §5.3-poortwachter, de actorlezer, de id-staart en de twee bezorgers.
 wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor });
+// De peilingen hun vier werktuigen (stap 8): de Update-bezorging voor de
+// telling, de id-staart, de verhuisweigering en de attributedTo-lezer.
+wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf });
 
 export default {
Index: src/services/ap-polls.js
===================================================================
--- src/services/ap-polls.js	(revision 984e0c6fb2d1b86cfe7692a1bfcb58441fa8519a)
+++ src/services/ap-polls.js	(revision 984e0c6fb2d1b86cfe7692a1bfcb58441fa8519a)
@@ -0,0 +1,217 @@
+/**
+ * ap-polls.js — de peilingen (stap 8 van shaer-drc).
+ *
+ * Beide kanten van een fediverse-poll:
+ *   - VREEMDE polls: parsePoll (het AS2 Question-formaat naar onze compacte
+ *     vorm) en de twee stemhandelingen (voteOnPoll uit de tijdlijncache,
+ *     voteOnRemotePoll op URL).
+ *   - EIGEN polls: de definitie op posts.poll_json, de telling uit de
+ *     stembiljetten (poll_votes), de Question-vorm op een note, het innemen
+ *     van een biljet en de gebundelde Update(Question) naar de volgers.
+ *
+ * Vier werktuigen uit de dienstlaag komen via wirePolls binnen; de rest wijst
+ * omlaag (db, ap-core, ap-transport).
+ */
+import db from '../config/database.js';
+import { actorId, AP_CONTEXT } from './ap-core.js';
+import { fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
+
+// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
+let deliverUpdate, rid, movedRefusal, actorUriOf;
+export function wirePolls(deps) {
+  ({ deliverUpdate, rid, movedRefusal, actorUriOf } = deps);
+}
+
+// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
+// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
+// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
+export function parsePoll(o) {
+  if (!o || o.type !== 'Question') return null;
+  const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
+  if (!raw || !raw.length) return null;
+  const options = raw.slice(0, 12).map((opt) => ({
+    name: String((opt && opt.name) || '').slice(0, 300),
+    count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
+  })).filter((x) => x.name);
+  if (!options.length) return null;
+  const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
+  const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
+  return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
+}
+
+// ── Polls WE host (a local post with a poll) ──────────────────────
+// Parse the poll definition stored on our own post (posts.poll_json). Counts are
+// NOT stored here — they're derived from the poll_votes ballots so a re-render always
+// reflects the authoritative tally.
+export function parseOwnPoll(pollJson) {
+  if (!pollJson) return null;
+  let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
+  if (!d || !Array.isArray(d.options)) return null;
+  const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
+  if (options.length < 2) return null;
+  const endTime = d.endTime || null;
+  const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
+  return { multiple: !!d.multiple, options, endTime, closed };
+}
+
+// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
+export function pollTally(postId) {
+  const counts = {}; let voters = 0;
+  try {
+    for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
+    voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
+  } catch { /* table may not exist yet */ }
+  return { counts, voters };
+}
+
+// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
+// Voting is fediverse-only, so this is display-only on the site.
+export function ownPollView(post) {
+  const poll = parseOwnPoll(post && post.poll_json);
+  if (!poll) return null;
+  const { counts, voters } = pollTally(post.id);
+  const total = Object.values(counts).reduce((a, b) => a + b, 0);
+  const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
+  const options = poll.options.map((o) => {
+    const count = counts[o.name] || 0;
+    return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
+  });
+  return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
+}
+
+// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
+// status with either media OR a poll (never both), so a poll federates as content +
+// options with no media attachment. oneOf = single choice, anyOf = multiple.
+export function applyPollToNote(note, postId, poll) {
+  const { counts, voters } = pollTally(postId);
+  const opts = poll.options.map((o) => ({
+    type: 'Note',
+    name: o.name,
+    replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
+  }));
+  note.type = 'Question';
+  note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
+  if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
+  // Once closed, Mastodon expects a `closed` timestamp (the effective end).
+  if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
+  note.votersCount = voters;
+  delete note.attachment;   // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
+  // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
+  // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
+  // Deleting it stripped the cover off every boosted poll.
+  return note;
+}
+
+// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
+// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
+// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
+// caller must NOT also store it as a reply), false means "not a poll, fall through".
+export function recordPollBallot(postId, actorUri, rawChoice) {
+  const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
+  if (!choice) return { handled: false };
+  let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
+  const poll = post && parseOwnPoll(post.poll_json);
+  if (!poll) return { handled: false };               // not a poll → let the reply logic handle it
+  if (poll.closed) return { handled: true };          // voting closed → drop
+  if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
+  try {
+    // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
+    // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
+    if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
+    db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
+  } catch { return { handled: true }; }
+  schedulePollUpdate(postId);
+  return { handled: true };
+}
+
+// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
+// refresh ~15s out; further votes in that window ride the same pending update (which carries
+// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
+const _pollUpdTimers = new Map();
+function schedulePollUpdate(postId) {
+  if (_pollUpdTimers.has(postId)) return;
+  const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
+  if (t.unref) t.unref();
+  _pollUpdTimers.set(postId, t);
+}
+
+// Push the fresh poll tally (or closed state) to followers as Update(Question).
+export async function deliverPollUpdate(postId) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !postId) return;
+  let post, site;
+  try {
+    post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
+    if (!post || !post.poll_json) return;
+    site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
+  } catch { return; }
+  if (site) await deliverUpdate(site, post);
+}
+
+// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
+// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
+// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
+// author's Update(Question) refreshes the authoritative totals when it arrives.
+export async function voteOnPoll(site, questionId, choices) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !site || !site.slug || !questionId) return { error: 'config' };
+  let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
+  if (!row || !row.poll_json) return { error: 'not_found' };
+  let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
+  if (poll.closed) return { error: 'closed' };
+  if (poll.voted) return { error: 'already' };
+  const valid = new Set(poll.options.map((o) => o.name));
+  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
+  if (!picks.length) return { error: 'invalid' };
+  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
+  const me = actorId(base, site.slug);
+  const keys = getOrCreateKeys(site.slug);
+  const authorUri = row.author_uri || null;
+  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
+  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
+  if (!inbox) return { error: 'unreachable' };
+  for (const name of chosen) {
+    const nid = `${me}/votes/${Date.now()}-${rid()}`;
+    const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
+    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
+    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
+  }
+  // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
+  poll.voted = poll.multiple ? chosen : chosen[0];
+  for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
+  if (poll.voters != null) poll.voters += 1;
+  try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
+  return { ok: true };
+}
+
+// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
+// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
+// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
+// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
+export async function voteOnRemotePoll(site, questionUrl, choices) {
+  const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
+  const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
+  if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
+  const poll = parsePoll(q);
+  if (!poll) return { error: 'not_found' };
+  if (poll.closed) return { error: 'closed' };
+  const valid = new Set(poll.options.map((o) => o.name));
+  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
+  if (!picks.length) return { error: 'invalid' };
+  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
+  const authorUri = actorUriOf(q.attributedTo);
+  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
+  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
+  if (!inbox) return { error: 'unreachable' };
+  const me = actorId(base, site.slug);
+  const keys = getOrCreateKeys(site.slug);
+  for (const name of chosen) {
+    const nid = `${me}/votes/${Date.now()}-${rid()}`;
+    const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
+    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
+    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
+  }
+  return { ok: true };
+}
