diff --git a/plugins/canopy/skills/update-plan/SKILL.md b/plugins/canopy/skills/update-plan/SKILL.md
index c4bab63..0721ab6 100644
--- a/plugins/canopy/skills/update-plan/SKILL.md
+++ b/plugins/canopy/skills/update-plan/SKILL.md
@@ -69,6 +69,9 @@ Once confirmed, make **exactly one** call:
 
 ## Hard rules (invariants)
 
+- **Server-gated to `ADMIN_LOGINS`.** `update_plan` is only registered for admin principals — a
+  non-admin bearer doesn't have the tool at all (absent from `tools/list`, tool-not-found if called).
+  This skill's own instructions are a second layer, not the enforcement boundary.
 - **Explicit only.** Never fire without a direct admin ask.
 - **Read before write, every time** — step 1 is not optional, even for a small edit.
 - **Confirm the diff before writing** — no silent writes.
diff --git a/src/auth/principal.ts b/src/auth/principal.ts
index 15c6166..3abe623 100644
--- a/src/auth/principal.ts
+++ b/src/auth/principal.ts
@@ -9,6 +9,16 @@ export interface Principal {
 
 export type AppEnv = { Bindings: Env; Variables: { principal: Principal } };
 
+/**
+ * Is this login an admin? ADMIN_LOGINS is a comma-separated allowlist of GitHub
+ * logins permitted to run admin actions (e.g. the server-side backfill). An
+ * absent/empty var means nobody is an admin — fails closed.
+ */
+export function isAdmin(env: Env, login: string): boolean {
+  const allow = (env.ADMIN_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
+  return allow.includes(login);
+}
+
 // The only routes reachable without a session. Everything else is gated.
 const PUBLIC_PATHS = new Set(["/auth/login", "/auth/callback"]);
 
diff --git a/src/auth/routes.ts b/src/auth/routes.ts
index 0fb6a1e..167de48 100644
--- a/src/auth/routes.ts
+++ b/src/auth/routes.ts
@@ -1,6 +1,7 @@
 import { Hono } from "hono";
 import { setCookie, getCookie, deleteCookie } from "hono/cookie";
 import type { AppEnv } from "./principal";
+import { isAdmin } from "./principal";
 import { pkce, randomToken, hmacSeal, hmacUnseal } from "./crypto";
 import { buildAuthorizeUrl, exchangeCode, getUser, isActiveOrgMember } from "./github";
 import { createSession, setSessionCookie, readSessionCookie, deleteSession, clearSessionCookie } from "./session";
@@ -73,7 +74,7 @@ authApp.get("/callback", async (c) => {
 authApp.get("/me", async (c) => {
   const login = c.get("principal").login;
   const row = await first<{ name: string | null; avatar_url: string | null }>(c.env.DB, `SELECT name, avatar_url FROM users WHERE github_login = ?`, login);
-  return c.json({ login, name: row?.name ?? null, avatar_url: row?.avatar_url ?? null, org: SAPLING_ORG });
+  return c.json({ login, name: row?.name ?? null, avatar_url: row?.avatar_url ?? null, org: SAPLING_ORG, admin: isAdmin(c.env, login) });
 });
 
 // GATED (by sessionGate in src/routes.ts): revoke this session.
diff --git a/src/env.ts b/src/env.ts
index 30755f8..1bbe49c 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -9,4 +9,5 @@ export interface Env {
   DEV_LOGIN?: string;     // LOCAL DEV ONLY (set in .dev.vars): bypass OAuth, act as this seeded user. Never set in prod.
   AI?: Ai;                // Workers AI binding: capture-time completed-PR summaries only, never at render.
   GITHUB_SERVICE_TOKEN?: string; // app-level token for the scheduled progress-cache recompute backstop; absent → scheduled() no-ops
+  ADMIN_LOGINS?: string;  // comma-separated GitHub logins allowed to run admin actions (e.g. the server-side backfill)
 }
diff --git a/src/mcp.ts b/src/mcp.ts
index b608f11..5c40eda 100644
--- a/src/mcp.ts
+++ b/src/mcp.ts
@@ -3,6 +3,7 @@ import { createMcpHandler } from "agents/mcp";
 import { z } from "zod";
 import type { Env } from "./env";
 import type { Principal } from "./auth/principal";
+import { isAdmin } from "./auth/principal";
 import { get_doc, list_docs, get_feed, query } from "./tools/reads";
 import { getMyWork, list_events } from "./tools/mywork";
 import { ingestFeedEntry, ingestDocProposal, consume } from "./consumer";
@@ -145,23 +146,29 @@ export function buildCanopyMcpServer(env: Env, principal: Principal): McpServer
     async (payload) => runTool(() => consume(env.DB, IngestPayload.parse(payload), principal)),
   );
 
-  server.tool(
-    "update_plan",
-    "ADMIN plan write: replace the roadmap narrative and create/update milestones (including status 'done') in one direct, non-destructively versioned write — same authored-write class as promote, NOT the ingestion gate. Milestones not listed are untouched. Use via the update-plan skill.",
-    {
-      narrative: z.string(),
-      milestones: z.array(z.object({
-        id: z.number().int().optional(),
-        title: z.string(),
-        description: z.string().nullable().optional(),
-        phase: z.string().nullable().optional(),
-        target_date: z.string(),
-        status: z.enum(["upcoming", "in_progress", "done"]),
-        github_ref: z.union([z.number(), z.array(z.number())]).nullable().optional(),
-      })).default([]),
-    },
-    async (input) => runTool(() => write_plan(env.DB, input as PlanWrite, principal.login))
-  );
+  // ADMIN-only: the plan write surface — non-admin principals don't even see the tool
+  // (conditional registration means it's absent from tools/list and calling it by
+  // name errors tool-not-found, since a fresh server is built per request with the
+  // principal already in scope).
+  if (isAdmin(env, principal.login)) {
+    server.tool(
+      "update_plan",
+      "ADMIN plan write: replace the roadmap narrative and create/update milestones (including status 'done') in one direct, non-destructively versioned write — same authored-write class as promote, NOT the ingestion gate. Milestones not listed are untouched. Use via the update-plan skill.",
+      {
+        narrative: z.string(),
+        milestones: z.array(z.object({
+          id: z.number().int().optional(),
+          title: z.string(),
+          description: z.string().nullable().optional(),
+          phase: z.string().nullable().optional(),
+          target_date: z.string(),
+          status: z.enum(["upcoming", "in_progress", "done"]),
+          github_ref: z.union([z.number(), z.array(z.number())]).nullable().optional(),
+        })).default([]),
+      },
+      async (input) => runTool(() => write_plan(env.DB, input as PlanWrite, principal.login))
+    );
+  }
 
   return server;
 }
diff --git a/src/routes.ts b/src/routes.ts
index 3a3fb58..731527a 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -1,9 +1,10 @@
 import { Hono } from "hono";
 import { IngestPayload } from "@shared/contract";
 import type { AppEnv } from "./auth/principal";
-import { sessionGate } from "./auth/principal";
+import { sessionGate, isAdmin } from "./auth/principal";
 import { authApp } from "./auth/routes";
 import { consume } from "./consumer";
+import { runBackfill } from "./tools/backfill";
 import { get_doc, list_docs, get_feed, query, list_needs_triage, list_adrs, list_milestone_proposals, list_proposals } from "./tools/reads";
 import { promote_doc, ratify_adr, promote_milestone_proposal, reject_milestone_proposal, complete_milestone, reject_doc_version, reject_adr, resolve_triage, assign_triage, type AssignType } from "./tools/writes";
 import { get_plan } from "./tools/plan";
@@ -219,6 +220,18 @@ app.post("/milestone-proposals/:id/reject", async (c) => {
   }
 });
 
+// ADMIN action (session-gated + admin-gated): server-side GitHub backfill.
+// A computed/authored direct writer in the promote class — humans (admins)
+// trigger it — but every captured event still funnels through the ingestEvent
+// gate fn. Non-admins get 403; a missing service token/repo → 503 with the error.
+app.post("/admin/backfill", async (c) => {
+  const login = c.get("principal").login;
+  if (!isAdmin(c.env, login)) return c.json({ error: "admin only" }, 403);
+  const res = await runBackfill(c.env, login);
+  if (!res.ok) return c.json({ error: res.error }, 503);
+  return c.json(res);
+});
+
 // Human confirmation (session-gated): flip a live milestone to 'done'.
 app.post("/milestones/:id/complete", async (c) => {
   const id = Number(c.req.param("id"));
diff --git a/src/tools/backfill.ts b/src/tools/backfill.ts
new file mode 100644
index 0000000..914f975
--- /dev/null
+++ b/src/tools/backfill.ts
@@ -0,0 +1,220 @@
+import type { Env } from "../env";
+import { nowIso } from "../db";
+import { ingestEvent } from "../consumer";
+import { eventsFromDelivery } from "../webhook";
+import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
+import { applyEventProgress } from "./progress";
+
+// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
+// (which signs synthetic webhook deliveries with the webhook secret), this runs
+// INSIDE the Worker with GITHUB_SERVICE_TOKEN — the same token the scheduled()
+// progress recompute uses — so it fetches GitHub REST directly, no webhook secret.
+//
+// It reconstructs the SAME deliveries the webhook would have received, reuses the
+// PURE eventsFromDelivery() derivation (never duplicated here), post-maps each
+// event's provenance to "backfill", and writes through the ONE gate fn ingestEvent
+// — but with the ADMIN principal as the writer (an authenticated identity), not
+// the fixed "github-webhook" string. Downstream projections (PR summaries, issue
+// progress) mirror handleGithubWebhook, hung off newly-written events only.
+
+const GH_API = "application/vnd.github+json";
+const USER_AGENT = "canopy";
+const DAYS_BACK = 14;
+
+export interface BackfillResult {
+  ok: boolean;
+  error?: string;
+  captured: number;
+  unchanged: number;
+  prs: number;
+  issues: number;
+}
+
+// Minimal typed views over the GitHub REST list items — only the fields the
+// delivery synthesizers below read are modeled; everything else is ignored.
+interface GhUserLite {
+  login: string;
+}
+interface GhMilestoneLite {
+  number: number;
+  open_issues?: number;
+  closed_issues?: number;
+}
+interface GhPrListItem {
+  number: number;
+  title: string;
+  body: string | null;
+  html_url: string;
+  merged_at: string | null;
+  closed_at: string | null;
+  updated_at: string;
+  user: GhUserLite;
+  milestone?: GhMilestoneLite | null;
+}
+interface GhIssueListItem {
+  number: number;
+  title: string;
+  html_url: string;
+  state: string;
+  updated_at: string;
+  user: GhUserLite;
+  assignees?: GhUserLite[];
+  assignee?: GhUserLite | null;
+  labels?: (string | { name: string })[];
+  milestone?: GhMilestoneLite | null;
+  pull_request?: unknown; // present only when the "issue" is really a PR
+}
+
+// The `rel="next"` URL from a GitHub Link header, or null when there is no next page.
+function nextLink(res: Response): string | null {
+  const link = res.headers.get("link");
+  const next = link?.split(",").find((part) => part.includes('rel="next"'));
+  return next ? (next.match(/<([^>]+)>/)?.[1] ?? null) : null;
+}
+
+// Synthesize the delivery bodies eventsFromDelivery reads — SAME raw slice shapes
+// as scripts/backfill-events.mjs (test/fixtures/*.json). PR list items carry no
+// `merged` boolean (that's single-PR-fetch only), so derive it from merged_at.
+function prClosedDelivery(pr: GhPrListItem) {
+  return {
+    action: "closed",
+    number: pr.number,
+    pull_request: {
+      number: pr.number,
+      title: pr.title,
+      body: pr.body,
+      html_url: pr.html_url,
+      merged: pr.merged_at != null,
+      merged_at: pr.merged_at,
+      closed_at: pr.closed_at,
+      user: { login: pr.user.login },
+      milestone: pr.milestone
+        ? { number: pr.milestone.number, open_issues: pr.milestone.open_issues, closed_issues: pr.milestone.closed_issues }
+        : null,
+    },
+  };
+}
+
+function issueDelivery(issue: GhIssueListItem) {
+  const assignee = issue.assignees?.[0] ?? issue.assignee ?? null;
+  const action = assignee ? "assigned" : "opened";
+  return {
+    action,
+    ...(assignee ? { assignee: { login: assignee.login } } : {}),
+    issue: {
+      number: issue.number,
+      title: issue.title,
+      html_url: issue.html_url,
+      state: issue.state,
+      updated_at: issue.updated_at,
+      user: { login: issue.user.login },
+      assignees: (issue.assignees ?? []).map((a) => ({ login: a.login })),
+      labels: issue.labels ?? [],
+      milestone: issue.milestone
+        ? { number: issue.milestone.number, open_issues: issue.milestone.open_issues, closed_issues: issue.milestone.closed_issues }
+        : null,
+    },
+  };
+}
+
+export async function runBackfill(
+  env: Env,
+  principalLogin: string,
+  opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
+): Promise<BackfillResult> {
+  const token = env.GITHUB_SERVICE_TOKEN;
+  const repo = env.GITHUB_REPO;
+  if (!token || !repo) {
+    return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
+  }
+
+  const doFetch = opts?.fetchImpl ?? fetch;
+  const summarizer = opts?.summarizer ?? (env.AI ? workersAiSummarizer(env.AI) : null);
+  const headers = {
+    authorization: `Bearer ${token}`,
+    accept: GH_API,
+    "user-agent": USER_AGENT,
+    "x-github-api-version": "2022-11-28",
+  };
+  const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;
+
+  // (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
+  //     paginating at the first item whose updated_at predates the cutoff.
+  const prList: GhPrListItem[] = [];
+  {
+    let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
+    let done = false;
+    while (url && !done) {
+      const res: Response = await doFetch(url, { headers });
+      if (!res.ok) break;
+      const page = (await res.json()) as GhPrListItem[];
+      for (const pr of page) {
+        if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
+          done = true;
+          break;
+        }
+        prList.push(pr);
+      }
+      url = done ? null : nextLink(res);
+    }
+  }
+
+  // (b) All open issues, paginated. The issues endpoint also returns PRs — those
+  //     carry a `pull_request` field and are not our surface, so skip them.
+  const issueList: GhIssueListItem[] = [];
+  {
+    let url: string | null = `https://api.github.com/repos/${repo}/issues?state=open&per_page=100`;
+    while (url) {
+      const res: Response = await doFetch(url, { headers });
+      if (!res.ok) break;
+      const page = (await res.json()) as GhIssueListItem[];
+      for (const issue of page) {
+        if (issue.pull_request) continue;
+        issueList.push(issue);
+      }
+      url = nextLink(res);
+    }
+  }
+
+  let captured = 0;
+  let unchanged = 0;
+
+  for (const pr of prList) {
+    const payload = prClosedDelivery(pr);
+    for (const base of eventsFromDelivery("pull_request", payload)) {
+      const ev = { ...base, provenance: "backfill" as const };
+      const res = await ingestEvent(env.DB, ev, principalLogin);
+      if (res.outcome === "written") {
+        captured++;
+        // Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
+        // store a capture-time summary (storePrSummary never throws).
+        const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
+        await storePrSummary(env.DB, summarizer, {
+          semantic_key: ev.semantic_key,
+          pr_number: parsed.pr.number,
+          title: parsed.pr.title,
+          body: parsed.pr.body ?? "",
+        });
+      } else {
+        unchanged++;
+      }
+    }
+  }
+
+  for (const issue of issueList) {
+    const payload = issueDelivery(issue);
+    for (const base of eventsFromDelivery("issues", payload)) {
+      const ev = { ...base, provenance: "backfill" as const };
+      const res = await ingestEvent(env.DB, ev, principalLogin);
+      if (res.outcome === "written") {
+        captured++;
+        // Mirror handleGithubWebhook's progress seam for newly-written issues.
+        await applyEventProgress(env.DB, payload);
+      } else {
+        unchanged++;
+      }
+    }
+  }
+
+  return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
+}
diff --git a/test/admin-route.test.ts b/test/admin-route.test.ts
new file mode 100644
index 0000000..92b6238
--- /dev/null
+++ b/test/admin-route.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from "vitest";
+import { env } from "cloudflare:test";
+import { app } from "../src/routes";
+import { createSession } from "../src/auth/session";
+import { hmacSeal } from "../src/auth/crypto";
+
+// Seal a session cookie for `login` (mirrors test/dashboard-route.test.ts:9-15).
+async function cookieFor(login: string): Promise<string> {
+  await env.DB.prepare(
+    `INSERT OR IGNORE INTO users (github_login, name, created_at) VALUES (?, ?, ?)`
+  ).bind(login, login, "2026-01-01T00:00:00Z").run();
+  const { id } = await createSession(env.DB, login);
+  return `session=${await hmacSeal(id, "test-cookie-secret")}`;
+}
+
+describe("POST /admin/backfill (session- + admin-gated)", () => {
+  it("401s without a session", async () => {
+    const res = await app.request("/admin/backfill", { method: "POST" }, env);
+    expect(res.status).toBe(401);
+  });
+
+  it("403s for a non-admin principal", async () => {
+    const res = await app.request(
+      "/admin/backfill",
+      { method: "POST", headers: { cookie: await cookieFor("not-admin") } },
+      env
+    );
+    expect(res.status).toBe(403);
+    expect(await res.json()).toEqual({ error: "admin only" });
+  });
+
+  it("passes the admin gate and 503s when the service token is unset (proves wiring, no network)", async () => {
+    // ADMIN_LOGINS binds "admin-user" in vitest.config.ts, so this login clears
+    // isAdmin. GITHUB_SERVICE_TOKEN is a secret never set in tests, so runBackfill
+    // returns ok:false BEFORE any GitHub fetch → 503 with the config error.
+    const res = await app.request(
+      "/admin/backfill",
+      { method: "POST", headers: { cookie: await cookieFor("admin-user") } },
+      env
+    );
+    expect(res.status).toBe(503);
+    expect(await res.json()).toEqual({ error: "service token or repo not configured" });
+  });
+});
diff --git a/test/backfill.test.ts b/test/backfill.test.ts
new file mode 100644
index 0000000..17294bf
--- /dev/null
+++ b/test/backfill.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from "vitest";
+import { env } from "cloudflare:test";
+import { all, first } from "../src/db";
+import { runBackfill } from "../src/tools/backfill";
+import type { Env } from "../src/env";
+import type { Summarizer } from "../src/tools/summarize";
+import type { EventRow, PrSummaryRow } from "@shared/rows";
+
+// Fixed "now" so the 14-day window is deterministic. threeDaysAgo is inside the
+// window; twentyDaysAgo is outside it (must be excluded — and, being sorted
+// updated-desc, must also stop pagination).
+const NOW = "2026-07-01T00:00:00Z";
+const threeDaysAgo = "2026-06-28T00:00:00Z";
+const twentyDaysAgo = "2026-06-11T00:00:00Z";
+
+// A Response-level fetch stub (the pool exports no fetch mock) — mirrors
+// test/roadmap.test.ts / test/progress.test.ts. Routes by path substring: the
+// pulls list vs the issues list.
+function stubFetch(prs: unknown[], issues: unknown[]): typeof fetch {
+  return (async (url: string | URL | Request) => {
+    const u = String(url);
+    const body = u.includes("/pulls") ? prs : u.includes("/issues") ? issues : [];
+    return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
+  }) as unknown as typeof fetch;
+}
+
+// Deterministic summarizer stub — never touches Workers AI.
+const summarizer: Summarizer = { model: "test-model", summarize: async () => "AI summary" };
+
+function envWith(overrides: Partial<Env> = {}): Env {
+  return { ...(env as unknown as Env), GITHUB_SERVICE_TOKEN: "svc-token", GITHUB_REPO: "o/r", ...overrides };
+}
+
+const mergedPr = {
+  number: 10,
+  title: "Add feature",
+  body: "This PR adds a feature.",
+  html_url: "https://github.com/o/r/pull/10",
+  merged_at: threeDaysAgo, // merged → derived merged:true from merged_at != null
+  closed_at: threeDaysAgo,
+  updated_at: threeDaysAgo,
+  user: { login: "octocat" },
+  milestone: null,
+};
+const oldPr = {
+  number: 5,
+  title: "Old PR",
+  body: "old",
+  html_url: "https://github.com/o/r/pull/5",
+  merged_at: twentyDaysAgo,
+  closed_at: twentyDaysAgo,
+  updated_at: twentyDaysAgo, // predates the cutoff → excluded (and stops pagination)
+  user: { login: "octocat" },
+  milestone: null,
+};
+const openIssue = {
+  number: 20,
+  title: "Fix bug",
+  html_url: "https://github.com/o/r/issues/20",
+  state: "open",
+  updated_at: threeDaysAgo,
+  user: { login: "octocat" },
+  assignees: [{ login: "octocat" }], // has an assignee → "assigned"
+  labels: ["bug"],
+  milestone: null,
+};
+const prAsIssue = {
+  number: 21,
+  title: "A PR the issues endpoint also returned",
+  html_url: "https://github.com/o/r/pull/21",
+  state: "open",
+  updated_at: threeDaysAgo,
+  user: { login: "octocat" },
+  pull_request: { url: "https://api.github.com/repos/o/r/pulls/21" }, // → skipped
+  assignees: [],
+  labels: [],
+  milestone: null,
+};
+
+describe("runBackfill", () => {
+  it("captures in-window closed PRs + open issues as backfill events written by the admin principal", async () => {
+    const res = await runBackfill(envWith(), "admin-user", {
+      fetchImpl: stubFetch([mergedPr, oldPr], [openIssue, prAsIssue]),
+      summarizer,
+      now: NOW,
+    });
+
+    expect(res.ok).toBe(true);
+    expect(res.prs).toBe(1); // oldPr excluded by the 14-day window
+    expect(res.issues).toBe(1); // prAsIssue excluded (pull_request present)
+    expect(res.captured).toBe(2);
+    expect(res.unchanged).toBe(0);
+
+    const events = await all<EventRow>(env.DB, `SELECT * FROM events ORDER BY ref_number`);
+    expect(events).toHaveLength(2);
+    for (const ev of events) {
+      expect(ev.provenance).toBe("backfill"); // provenance post-mapped from "webhook"
+      expect(ev.recorded_by).toBe("admin-user"); // writer is the ADMIN principal, not "github-webhook"
+    }
+    // The merged PR was captured as pr_merged (merged_at != null).
+    const pr = events.find((e) => e.ref_number === 10)!;
+    expect(pr.event_type).toBe("pr_merged");
+    expect(pr.subject_login).toBe("octocat");
+
+    // The PR summary projection ran for the newly-written PR event.
+    const summary = await first<PrSummaryRow>(env.DB, `SELECT * FROM pr_summaries WHERE pr_number = ?`, 10);
+    expect(summary).toBeTruthy();
+    expect(summary?.summary).toBe("AI summary");
+  });
+
+  it("is idempotent — a second run over the same GitHub state writes nothing new", async () => {
+    const fetchImpl = stubFetch([mergedPr], [openIssue]);
+    const first = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW });
+    expect(first.captured).toBe(2);
+
+    const second = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW });
+    expect(second.ok).toBe(true);
+    expect(second.captured).toBe(0);
+    expect(second.unchanged).toBe(2);
+    expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(2); // INSERT OR IGNORE on semantic_key
+  });
+
+  it("returns {ok:false} (no throw, no writes) when the service token is missing", async () => {
+    const res = await runBackfill(envWith({ GITHUB_SERVICE_TOKEN: undefined }), "admin-user", {
+      fetchImpl: stubFetch([mergedPr], [openIssue]),
+      summarizer,
+      now: NOW,
+    });
+    expect(res.ok).toBe(false);
+    expect(res.error).toContain("service token or repo");
+    expect(res).toMatchObject({ captured: 0, unchanged: 0, prs: 0, issues: 0 });
+    expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(0);
+  });
+});
diff --git a/test/env.d.ts b/test/env.d.ts
index e097c21..c070f80 100644
--- a/test/env.d.ts
+++ b/test/env.d.ts
@@ -16,6 +16,7 @@ declare global {
       GITHUB_WEBHOOK_SECRET?: string;
       GITHUB_REPO?: string;
       GITHUB_SERVICE_TOKEN?: string;
+      ADMIN_LOGINS?: string;
     }
   }
 }
diff --git a/test/mcp.plan.test.ts b/test/mcp.plan.test.ts
index c2f6c67..170b5fb 100644
--- a/test/mcp.plan.test.ts
+++ b/test/mcp.plan.test.ts
@@ -6,32 +6,39 @@ import { buildCanopyMcpServer } from "../src/mcp";
 import { all, first } from "../src/db";
 import type { MilestoneRow, PlanRow } from "@shared/rows";
 
-const AUTHOR = "admin-agent";
+// ADMIN_LOGINS binds "admin-user" in vitest.config.ts — this login clears isAdmin().
+const AUTHOR = "admin-user";
 
 // Drive the ACTUAL registered MCP `update_plan` tool through an in-memory MCP
 // client/server pair — the same closure production runs (mirrors
 // test/mcp.append_feed.test.ts's callTool helper).
-async function callTool(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
-  const server = buildCanopyMcpServer(env as unknown as import("../src/env").Env, { login: AUTHOR });
+async function withClient<T>(login: string, fn: (client: Client) => Promise<T>): Promise<T> {
+  const server = buildCanopyMcpServer(env as unknown as import("../src/env").Env, { login });
   const client = new Client({ name: "test", version: "1.0.0" });
   const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
   await server.connect(serverTransport);
   await client.connect(clientTransport);
   try {
+    return await fn(client);
+  } finally {
+    await client.close();
+    await server.close();
+  }
+}
+
+async function callTool(login: string, name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
+  return withClient(login, async (client) => {
     const res = (await client.callTool({ name, arguments: args })) as {
       content: Array<{ type: string; text: string }>;
       isError?: boolean;
     };
     return { text: res.content[0].text, isError: res.isError };
-  } finally {
-    await client.close();
-    await server.close();
-  }
+  });
 }
 
 describe("registered MCP update_plan tool", () => {
   it("writes as the bearer principal — author stamped from the principal, never the payload", async () => {
-    const res = await callTool("update_plan", {
+    const res = await callTool(AUTHOR, "update_plan", {
       narrative: "shipped via MCP",
       milestones: [{ title: "MCP Milestone", target_date: "2026-08-01", status: "upcoming" }],
     });
@@ -51,10 +58,35 @@ describe("registered MCP update_plan tool", () => {
   });
 
   it("milestones default to [] when omitted", async () => {
-    const res = await callTool("update_plan", { narrative: "narrative only" });
+    const res = await callTool(AUTHOR, "update_plan", { narrative: "narrative only" });
     expect(res.isError).toBeFalsy();
     const body = JSON.parse(res.text);
     expect(body.version).toBe(1);
     expect(body.milestones).toEqual([]);
   });
 });
+
+describe("update_plan is admin-only — non-admin principals don't even get the tool", () => {
+  it("a non-admin's tools/list omits update_plan but still includes get_roadmap", async () => {
+    const { tools } = await withClient("agent", (client) => client.listTools());
+    const names = tools.map((t) => t.name);
+    expect(names).not.toContain("update_plan");
+    expect(names).toContain("get_roadmap");
+  });
+
+  it("an admin's tools/list includes update_plan", async () => {
+    const { tools } = await withClient(AUTHOR, (client) => client.listTools());
+    const names = tools.map((t) => t.name);
+    expect(names).toContain("update_plan");
+  });
+
+  it("a non-admin calling update_plan gets a tool-not-found MCP error", async () => {
+    const res = await callTool("agent", "update_plan", { narrative: "should not land" });
+    expect(res.isError).toBeTruthy();
+    expect(res.text.toLowerCase()).toContain("not found");
+
+    // Nothing was written — the seeded plan row is untouched.
+    const plan = await first<PlanRow>(env.DB, `SELECT * FROM plan WHERE id = 1`);
+    expect(plan?.narrative).not.toBe("should not land");
+  });
+});
diff --git a/test/render.mywork.test.ts b/test/render.mywork.test.ts
index c0ec34c..59b2cec 100644
--- a/test/render.mywork.test.ts
+++ b/test/render.mywork.test.ts
@@ -140,13 +140,13 @@ describe("todoCard", () => {
 // ── full render() — My Work screen composition ──────────────────────────────
 
 describe("render() — My Work screen", () => {
-  function stateWithDashboard(data: DashboardData): ReturnType<typeof initialState> {
+  function stateWithDashboard(data: DashboardData, admin = false): ReturnType<typeof initialState> {
     const s = initialState();
     return {
       ...s,
       view: "app",
       screen: "mywork",
-      me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" },
+      me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin },
       mywork: { status: "ok", data },
     };
   }
@@ -203,4 +203,18 @@ describe("render() — My Work screen", () => {
     expect(html).toContain("Previous activity");
     expect(html).toContain("To-do");
   });
+
+  // ── admin-only Sync GitHub button (server-side backfill trigger) ────────────
+  it("renders the Sync GitHub backfill button for an admin me", () => {
+    const data: DashboardData = { person: "alice", previousActivity: [], todo: [], degraded: false };
+    const html = render(stateWithDashboard(data, true));
+    expect(html).toContain('data-act="adminBackfill"');
+    expect(html).toContain("Sync GitHub");
+  });
+
+  it("does NOT render the Sync GitHub button for a non-admin me", () => {
+    const data: DashboardData = { person: "alice", previousActivity: [], todo: [], degraded: false };
+    const html = render(stateWithDashboard(data, false));
+    expect(html).not.toContain('data-act="adminBackfill"');
+  });
 });
diff --git a/test/render.roadmap.test.ts b/test/render.roadmap.test.ts
index 23babe2..f669d5a 100644
--- a/test/render.roadmap.test.ts
+++ b/test/render.roadmap.test.ts
@@ -72,7 +72,7 @@ function stateWithPlan(plan: PlanView, tab: "narrative" | "timeline"): ReturnTyp
     view: "app",
     screen: "roadmap",
     roadmapTab: tab,
-    me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" },
+    me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false },
     roadmap: { status: "ok", data: plan },
   };
 }
@@ -190,7 +190,7 @@ describe("search results — milestone hits navigate via goRoadmap", () => {
       ...s,
       view: "app",
       screen: "search",
-      me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" },
+      me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false },
       searchResults: {
         status: "ok",
         data: {
@@ -215,7 +215,7 @@ describe("search results — milestone hits navigate via goRoadmap", () => {
       ...s,
       view: "app",
       screen: "search",
-      me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn" },
+      me: { login: "alice", name: "Alice", avatar_url: null, org: "SaplingLearn", admin: false },
       searchResults: {
         status: "ok",
         data: {
diff --git a/test/render.triage.test.ts b/test/render.triage.test.ts
index 5f337bf..f4521b4 100644
--- a/test/render.triage.test.ts
+++ b/test/render.triage.test.ts
@@ -392,7 +392,7 @@ describe("XSS: proposal slug in triage list and detail is attribute-escaped", ()
       view: "app",
       screen: "triage",
       triageQueue: "proposals",
-      me: { login: "reviewer", name: null, avatar_url: null, org: "SaplingLearn" },
+      me: { login: "reviewer", name: null, avatar_url: null, org: "SaplingLearn", admin: false },
       proposals: { status: "ok", data: [proposal] },
       selProposal: key,
     };
diff --git a/vitest.config.ts b/vitest.config.ts
index d96e286..b08db02 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -31,6 +31,7 @@ export default defineConfig({
           GITHUB_CLIENT_ID: "test-client-id",
           GITHUB_CLIENT_SECRET: "test-client-secret",
           GITHUB_WEBHOOK_SECRET: "test-webhook-secret",
+          ADMIN_LOGINS: "admin-user", // the admin allowlist the admin-gated route + isAdmin() test against
           DEV_LOGIN: "", // override .dev.vars: tests exercise REAL auth, never the dev bypass
         },
       },
diff --git a/web/src/api.ts b/web/src/api.ts
index aea01a8..e12f064 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -119,11 +119,17 @@ export function listMilestoneProposals(): Promise<MilestoneProposalRow[]> {
   return getJson<{ proposals: MilestoneProposalRow[] }>("/milestone-proposals").then((r) => r.proposals);
 }
 
-export interface Me { login: string; name: string | null; avatar_url: string | null; org: string; }
+export interface Me { login: string; name: string | null; avatar_url: string | null; org: string; admin: boolean; }
 export function getMe(): Promise<Me> {
   return getJson<Me>("/auth/me");
 }
 
+// ADMIN action: trigger the server-side GitHub backfill (admin-only route). The
+// worker holds the service token and fetches GitHub directly — no webhook secret.
+export function adminBackfill(): Promise<{ ok: boolean; captured: number; unchanged: number; prs: number; issues: number }> {
+  return postJson("/admin/backfill", {});
+}
+
 export function getMyDashboard(): Promise<DashboardData> {
   return getJson<DashboardData>("/me/dashboard");
 }
diff --git a/web/src/main.ts b/web/src/main.ts
index 9984b7e..ff5f655 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -10,7 +10,7 @@ import {
   listStagedProposals, listAdrs, listNeedsTriage, listMilestoneProposals,
   promoteDoc, ratifyAdr, completeMilestone, promoteMilestoneProposal,
   rejectDoc, rejectAdr, rejectMilestoneProposal, discardTriage, assignTriage,
-  getMe, logout, mintMcpToken,
+  getMe, logout, mintMcpToken, adminBackfill,
   Unauthorized, NotFound, ApiError,
 } from "./api";
 
@@ -477,6 +477,18 @@ function dispatch(act: string, arg: string | null, value: string | null): void {
         });
       return;
     }
+    // ADMIN action (My Work): trigger the server-side GitHub backfill, then
+    // refresh My Work so newly-captured PRs/issues surface in the two lists.
+    case "adminBackfill": {
+      flash("Syncing GitHub&hellip;");
+      adminBackfill()
+        .then((r) => { flash(`Synced: ${r.captured} captured, ${r.unchanged} unchanged`); loadMyWork(); })
+        .catch((e) => {
+          if (e instanceof Unauthorized) { state.view = "auth"; state.authStep = "login"; rerender(); return; }
+          flash(e instanceof ApiError ? e.message : "Sync failed");
+        });
+      return;
+    }
     // ── Phase 3 triage write-back (cookie-authed) ───────────────────────────
     // Dismiss = reject. Shared by the Proposals queue (arg "slug@version" → reject
     // the staged doc version), the Decisions queue (arg id → reject the ADR), and
diff --git a/web/src/render.ts b/web/src/render.ts
index bc3405b..e8e0872 100644
--- a/web/src/render.ts
+++ b/web/src/render.ts
@@ -336,6 +336,14 @@ function header(s: AppState): string {
       <button data-act="roadmapTimeline" style="${rmTabStyle("timeline")}">Timeline${overdueCount ? `<span style="width:6px;height:6px;border-radius:50%;background:var(--red);margin-left:1px"></span>` : ""}</button>
     </div>` : "";
 
+  // ADMIN-only, My Work screen: trigger the server-side GitHub backfill. Rendered
+  // only when /auth/me returned admin:true (outline button, promote-class action).
+  const myworkControls = s.screen === "mywork" && s.me?.admin
+    ? `<button data-act="adminBackfill" title="Fetch recent GitHub PRs + issues" class="cnpy-outlinebtn" style="display:flex;align-items:center;gap:7px;padding:6px 12px;border-radius:8px;border:1px solid var(--border-strong);font-size:12.5px;font-weight:500;color:var(--fg-70)">
+      <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"></path><path d="M21 3v5h-5"></path></svg>
+      Sync GitHub
+    </button>` : "";
+
   const themeBtn = `<button data-act="cycleTheme" title="Toggle theme" class="cnpy-iconbtn" style="width:32px;height:32px;border-radius:8px;border:1px solid var(--border);display:grid;place-items:center;color:var(--fg-55)">
       ${dark
         ? `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"></path></svg>`
@@ -348,7 +356,7 @@ function header(s: AppState): string {
       ${filterChip}
     </div>
     <div style="display:flex;align-items:center;gap:8px;flex:none">
-      ${feedControls}${triageControls}${docsControls}${roadmapControls}${themeBtn}
+      ${feedControls}${triageControls}${docsControls}${roadmapControls}${myworkControls}${themeBtn}
     </div>
   </header>`;
 }
diff --git a/wrangler.toml b/wrangler.toml
index e84f315..b5e8f3a 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -18,6 +18,8 @@ database_id = "80386dc4-deef-461d-932a-0670d22ddf83"
 
 [vars]
 GITHUB_REPO = "SaplingLearn/sapling"
+# Comma-separated GitHub logins allowed to run admin actions (e.g. the server-side backfill).
+ADMIN_LOGINS = "AndresL230"
 
 [observability]
 enabled = true
