fix(billing): the subscription repair queue is operator-only; customers meter on their quota #5451
3 changed files+53−20
Modified.claude/commands/drain-repairs.md+8−0View fileUnifiedSplit
@@ -34,6 +34,14 @@ subscription. Your job: drain the `ai:repair` queue safely.
3434 - NEVER merge a PR. Every change lands as a PR for the owner's review.
3535 - NEVER push to a default branch.
3636 - NEVER touch repos or issues without the `ai:repair` label.
37 - **Owner-authored only.** This agent runs on the owner's paid Claude
38 subscription. Before implementing any queued repair, verify the issue
39 author is the platform owner (ccantynz) — the label alone is NOT
40 authorization, since anyone with triage rights can apply a label. A
41 customer-authored `ai:repair` issue gets a polite comment ("the
42 internal repair agent serves platform maintenance; AI repairs for
43 your repo run through your AI quota — see /billing/usage") and is
44 de-labelled if possible, never implemented.
3745 - Large risky specs (e.g. security refactors across many files): do at
3846 most one such repair per run, carefully, with tests where possible.
3947 - Cap the run at 3 repairs; leave the rest for the next scheduled run.
Modifiedsrc/routes/health.tsx+8−6View fileUnifiedSplit
@@ -27,6 +27,7 @@ import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
2727import { createSpecPR } from "../lib/spec-to-pr";
2828import { internalAiMode } from "../lib/ai-client";
2929import { queueRepairIssue } from "../lib/repair-queue";
30import { isSiteAdmin } from "../lib/admin";
3031
3132const health = new Hono<AuthEnv>();
3233
@@ -516,12 +517,13 @@ health.post("/:owner/:repo/health/repair", async (c) => {
516517 .filter(Boolean)
517518 .join("\n");
518519
519 // Owner decision 2026-08-08: internal repairs run on the owner's Claude
520 // subscription, not API credits. In "agent" mode (the default) the repair
521 // is queued as an `ai:repair` issue that a subscription-backed Claude
522 // Code agent drains — it implements the fix and opens the PR. "api" mode
523 // (admin-switchable) calls the Anthropic API directly.
524 if (internalAiMode() === "agent") {
520 // Owner decision 2026-08-08: INTERNAL repairs run on the owner's Claude
521 // subscription — in "agent" mode they queue as `ai:repair` issues that a
522 // subscription-backed Claude Code agent drains. That path is SITE-ADMIN
523 // ONLY: a customer's click must never become free labor billed to the
524 // owner's subscription. Customers always take the API path below, where
525 // createSpecPR meters the spend against their own AI quota.
526 if (internalAiMode() === "agent" && (await isSiteAdmin(user.id))) {
525527 const issueNumber = await queueRepairIssue({
526528 repositoryId: repoRow.id,
527529 authorId: user.id,
Modifiedsrc/routes/specs.tsx+37−14View fileUnifiedSplit
@@ -1288,11 +1288,14 @@ function ProgressPage({
12881288 owner,
12891289 repo,
12901290 job,
1291 canQueue,
12911292}: {
12921293 owner: string;
12931294 repo: string;
12941295 jobId: string;
12951296 job: SpecJob;
1297 /** Site admins only — the repair queue runs on the owner's subscription. */
1298 canQueue?: boolean;
12961299}) {
12971300 const stageIdx = (s: SpecStage) => {
12981301 const order: SpecStage[] = ["queued", "analyzing", "writing", "opening_pr", "done", "error"];
@@ -1379,21 +1382,25 @@ function ProgressPage({
13791382 >
13801383 <p id="sp-error-msg">{job.error || ""}</p>
13811384 <div style="display: flex; gap: 14px; align-items: center">
1382 <form
1383 method="post"
1384 action={`/${owner}/${repo}/spec/${job.id}/queue`}
1385 style="margin: 0"
1386 >
1387 <button type="submit" class="sp-queue-btn">
1388 Queue for the repair agent
1389 </button>
1390 </form>
1385 {canQueue && (
1386 <form
1387 method="post"
1388 action={`/${owner}/${repo}/spec/${job.id}/queue`}
1389 style="margin: 0"
1390 >
1391 <button type="submit" class="sp-queue-btn">
1392 Queue for the repair agent
1393 </button>
1394 </form>
1395 )}
13911396 <a href={`/${owner}/${repo}/spec`}>Try again</a>
13921397 </div>
1393 <p style="font-size: 12px; color: var(--text-muted); margin: 8px 0 0">
1394 Queuing files your spec as an <code>ai:repair</code> issue — the
1395 internal agent implements it and opens the PR, no AI balance needed.
1396 </p>
1398 {canQueue && (
1399 <p style="font-size: 12px; color: var(--text-muted); margin: 8px 0 0">
1400 Queuing files your spec as an <code>ai:repair</code> issue — the
1401 internal agent implements it and opens the PR, no AI balance needed.
1402 </p>
1403 )}
13971404 </div>
13981405
13991406 <script dangerouslySetInnerHTML={{ __html: PROGRESS_POLL_JS }} />
@@ -1422,10 +1429,19 @@ specs.get("/:owner/:repo/spec/:jobId/progress", softAuth, requireAuth, async (c)
14221429 return c.redirect(`/${owner}/${repo}/pulls/${job.prNumber}`);
14231430 }
14241431
1432 const { isSiteAdmin } = await import("../lib/admin");
1433 const canQueue = await isSiteAdmin(user.id);
1434
14251435 return c.html(
14261436
14271437 <RepoHeader owner={owner} repo={repo} />
1428 <ProgressPage owner={owner} repo={repo} jobId={jobId} job={job} />
1438 <ProgressPage
1439 owner={owner}
1440 repo={repo}
1441 jobId={jobId}
1442 job={job}
1443 canQueue={canQueue}
1444 />
14291445 </Layout>
14301446 );
14311447});
@@ -1451,6 +1467,13 @@ specs.post("/:owner/:repo/spec/:jobId/queue", softAuth, requireAuth, async (c) =
14511467 return c.redirect(`/${owner}/${repo}/spec/${jobId}/progress`);
14521468 }
14531469
1470 // The internal repair queue drains on the OWNER's Claude subscription —
1471 // site admins only. Customers retry via the API path when it's available.
1472 const { isSiteAdmin } = await import("../lib/admin");
1473 if (!(await isSiteAdmin(user.id))) {
1474 return c.text("Forbidden — the repair queue is operator-only", 403);
1475 }
1476
14541477 const { queueRepairIssue } = await import("../lib/repair-queue");
14551478 const firstLine =
14561479 job.spec
14571480
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts