# Issue Clearance Plan — All Open Issues

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Close all automatable open GitHub issues: a11y PR, #290 Phase 5B auto-removal, #402 handoff doc update, #134 soft launch prep.

**Architecture:** Sequential PRs. Each task is a branch → commit → PR cycle. Human-gated issues (#359–363, #319, #134 launch date) get notes added but stay open.

**Tech Stack:** Next.js 16 App Router, TypeScript strict, Prisma, Python FastAPI, pytest, Vitest

---

## Task 1: PR the two a11y commits

**Files:** already committed on `fix/e2e-ci-smoke-and-marketplace-route-test`

**Step 1: Push branch and open PR**

```bash
git push origin fix/e2e-ci-smoke-and-marketplace-route-test
gh pr create \
  --title "fix(a11y): resolve WCAG 2.2 AA violations in E2E smoke suite" \
  --body "$(cat <<'EOF'
## Summary
- Remove invalid `<a><button>` nesting in hero CTA — fixes `target-size partiallyObscured` axe violation
- `text-gray-500/600` → `text-gray-400` on auth footer, docs sidebar, CodeBlock language labels — fixes color-contrast violations
- Hero CTA `gap-3` → `gap-6` — fixes WCAG 2.2 target-offset (adjacent targets need 24px separation)

All changes surface from E2E smoke test axe-core scan. Zero functional changes.

## Test plan
- [ ] CI passes (smoke E2E re-runs axe on all pages)
- [ ] No new color-contrast failures in axe report

Closes #- (no specific issue, follow-up to PR #421)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

**Step 2: Verify CI passes, merge**

---

## Task 2: Phase 5B auto-removal — marketplace nodes below reputation threshold

**Files:**
- Create: `frontend/src/app/api/internal/marketplace/auto-remove/route.ts`
- Modify: `llmhosts/marketplace/probes.py` (add auto-removal call after probe cycle)
- Create: `frontend/src/__tests__/api/internal/marketplace/auto-remove.test.ts`
- Create: `tests/marketplace/test_probe_auto_remove.py`
- Modify: `frontend/src/app/api/marketplace/nodes/route.ts` (exclude Unverified nodes by default)

### Step 1: Write the failing frontend test

```typescript
// frontend/src/__tests__/api/internal/marketplace/auto-remove.test.ts
import { describe, it, expect, beforeEach, vi } from "vitest";

vi.mock("@/lib/db", () => ({
  db: { device: { updateMany: vi.fn() } },
}));

describe("POST /api/internal/marketplace/auto-remove", () => {
  beforeEach(() => vi.clearAllMocks());

  it("returns 401 without internal secret", async () => {
    const { POST } = await import("@/app/api/internal/marketplace/auto-remove/route");
    const res = await POST(new Request("http://localhost", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ device_ids: ["dev-1"] }),
    }));
    expect(res.status).toBe(401);
  });

  it("disables marketplace for low-reputation devices", async () => {
    process.env.INTERNAL_MARKETPLACE_SECRET = "test-secret";
    const { db } = await import("@/lib/db");
    vi.mocked(db.device.updateMany).mockResolvedValue({ count: 2 });

    const { POST } = await import("@/app/api/internal/marketplace/auto-remove/route");
    const res = await POST(new Request("http://localhost", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-internal-secret": "test-secret",
      },
      body: JSON.stringify({ device_ids: ["dev-1", "dev-2"] }),
    }));

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.disabled).toBe(2);
    expect(db.device.updateMany).toHaveBeenCalledWith({
      where: { id: { in: ["dev-1", "dev-2"] }, marketplaceEnabled: true },
      data: { marketplaceEnabled: false },
    });
  });

  it("returns 400 when device_ids is missing", async () => {
    process.env.INTERNAL_MARKETPLACE_SECRET = "test-secret";
    const { POST } = await import("@/app/api/internal/marketplace/auto-remove/route");
    const res = await POST(new Request("http://localhost", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-internal-secret": "test-secret",
      },
      body: JSON.stringify({}),
    }));
    expect(res.status).toBe(400);
  });
});
```

**Step 2: Run test to verify it fails**

```bash
cd frontend && npm run test -- --reporter=verbose src/__tests__/api/internal/marketplace/auto-remove.test.ts
```

Expected: FAIL — module not found

**Step 3: Create the auto-remove route**

```typescript
// frontend/src/app/api/internal/marketplace/auto-remove/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";

const INTERNAL_SECRET_HEADER = "x-internal-secret";

/**
 * POST /api/internal/marketplace/auto-remove
 *
 * Called by the Python probe loop after each cycle to disable marketplace
 * participation for nodes whose reputation score has fallen below the
 * Unverified threshold (score < 50). Protected by INTERNAL_MARKETPLACE_SECRET.
 *
 * Body: { device_ids: string[] }
 * Response: { disabled: number }
 */
export async function POST(request: NextRequest) {
  const secret = request.headers.get(INTERNAL_SECRET_HEADER);
  const expected = process.env.INTERNAL_MARKETPLACE_SECRET;
  if (!expected || secret !== expected) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  let body: { device_ids?: unknown };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
  }

  const { device_ids } = body;
  if (!Array.isArray(device_ids) || device_ids.length === 0) {
    return NextResponse.json(
      { error: "device_ids must be a non-empty array" },
      { status: 400 },
    );
  }

  const ids = device_ids.filter((id): id is string => typeof id === "string");
  if (ids.length === 0) {
    return NextResponse.json({ error: "No valid device IDs provided" }, { status: 400 });
  }

  const result = await db.device.updateMany({
    where: { id: { in: ids }, marketplaceEnabled: true },
    data: { marketplaceEnabled: false },
  });

  logger.info(
    { device_ids: ids, disabled: result.count },
    "marketplace_auto_remove",
  );

  return NextResponse.json({ disabled: result.count, device_ids: ids });
}
```

**Step 4: Run test to verify it passes**

```bash
cd frontend && npm run test -- --reporter=verbose src/__tests__/api/internal/marketplace/auto-remove.test.ts
```

Expected: PASS

**Step 5: Write failing Python test**

```python
# tests/marketplace/test_probe_auto_remove.py
"""Tests for probe loop auto-removal of low-reputation nodes."""
import asyncio
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.marketplace.probes import ProbeResult, _check_and_remove_low_reputation


@pytest.mark.asyncio
async def test_check_and_remove_calls_api_for_low_score_nodes():
    """Nodes below REPUTATION_REMOVAL_THRESHOLD trigger auto-remove API call."""
    results = [
        ProbeResult(
            device_id="dev-bad",
            timestamp=datetime.now(timezone.utc),
            latency_ms=9999.0,
            success=False,
        ),
        ProbeResult(
            device_id="dev-good",
            timestamp=datetime.now(timezone.utc),
            latency_ms=50.0,
            success=True,
        ),
    ]

    store = MagicMock()
    store.uptime_30d = AsyncMock(side_effect=lambda device_id: 10.0 if device_id == "dev-bad" else 95.0)

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.post = AsyncMock(return_value=MagicMock(status_code=200))
        mock_client_cls.return_value = mock_client

        await _check_and_remove_low_reputation(
            results=results,
            store=store,
            control_plane_url="https://llmhosts.com",
            internal_secret="test-secret",
        )

        mock_client.post.assert_called_once()
        call_kwargs = mock_client.post.call_args
        assert "dev-bad" in call_kwargs[1]["json"]["device_ids"]
        assert "dev-good" not in call_kwargs[1]["json"]["device_ids"]


@pytest.mark.asyncio
async def test_check_and_remove_skips_when_no_low_score_nodes():
    """No API call when all nodes are healthy."""
    results = [
        ProbeResult(
            device_id="dev-good",
            timestamp=datetime.now(timezone.utc),
            latency_ms=50.0,
            success=True,
        ),
    ]

    store = MagicMock()
    store.uptime_30d = AsyncMock(return_value=99.0)

    with patch("httpx.AsyncClient") as mock_client_cls:
        await _check_and_remove_low_reputation(
            results=results,
            store=store,
            control_plane_url="https://llmhosts.com",
            internal_secret="test-secret",
        )
        mock_client_cls.assert_not_called()
```

**Step 6: Run test to verify it fails**

```bash
cd /home/dev/projects/LLMHosts.com && pytest tests/marketplace/test_probe_auto_remove.py -v
```

Expected: FAIL — `_check_and_remove_low_reputation` not found

**Step 7: Add auto-removal to `llmhosts/marketplace/probes.py`**

Add after the imports and constants:

```python
# Auto-removal threshold: nodes below this uptime/reputation score are disabled
REPUTATION_REMOVAL_THRESHOLD = 30.0  # 30% uptime → auto-remove

async def _check_and_remove_low_reputation(
    results: list[ProbeResult],
    store: "ProbeStore",
    *,
    control_plane_url: str,
    internal_secret: str,
) -> None:
    """After a probe cycle, disable marketplace nodes whose 30-day uptime is below threshold.

    Calls POST {control_plane_url}/api/internal/marketplace/auto-remove with the
    list of device IDs that are consistently failing probes.
    """
    if not results or not control_plane_url or not internal_secret:
        return

    low_reputation_ids: list[str] = []
    for result in results:
        uptime = await store.uptime_30d(result.device_id)
        if uptime < REPUTATION_REMOVAL_THRESHOLD:
            low_reputation_ids.append(result.device_id)
            logger.info(
                "Auto-removing marketplace node %s: uptime=%.1f%% < %.1f%% threshold",
                result.device_id,
                uptime,
                REPUTATION_REMOVAL_THRESHOLD,
            )

    if not low_reputation_ids:
        return

    url = f"{control_plane_url.rstrip('/')}/api/internal/marketplace/auto-remove"
    async with httpx.AsyncClient() as client:
        try:
            resp = await client.post(
                url,
                json={"device_ids": low_reputation_ids},
                headers={"x-internal-secret": internal_secret},
                timeout=10.0,
            )
            logger.info(
                "Auto-remove API response: status=%d, disabled=%s",
                resp.status_code,
                resp.json().get("disabled", "?") if resp.status_code == 200 else "err",
            )
        except Exception:
            logger.warning("Auto-remove API call failed", exc_info=True)
```

Then update `run_probe_loop` to call it, adding `control_plane_url` and `internal_secret` params:

```python
async def run_probe_loop(
    catalog_url: str,
    store: ProbeStore,
    *,
    interval_seconds: int = _DEFAULT_INTERVAL_SECONDS,
    stop_event: asyncio.Event | None = None,
    control_plane_url: str = "",
    internal_secret: str = "",
) -> None:
    """Background loop: every *interval_seconds* fetch nodes, probe each, store results.

    When *control_plane_url* and *internal_secret* are provided, auto-removes
    nodes whose 30-day uptime falls below REPUTATION_REMOVAL_THRESHOLD.
    """
    from llmhosts.marketplace.router import fetch_nodes

    if not (catalog_url or "").strip():
        logger.warning("run_probe_loop: empty catalog_url, not starting")
        return
    if stop_event is None:
        stop_event = asyncio.Event()
    while True:
        try:
            nodes = await fetch_nodes(catalog_url)
            cycle_results: list[ProbeResult] = []
            for node in nodes:
                result = await run_probe(node)
                await store.append(result)
                cycle_results.append(result)
            logger.debug("Probe loop: probed %d nodes", len(nodes))
            if control_plane_url and internal_secret:
                await _check_and_remove_low_reputation(
                    cycle_results,
                    store,
                    control_plane_url=control_plane_url,
                    internal_secret=internal_secret,
                )
        except Exception:
            logger.warning("Probe loop iteration failed", exc_info=True)
        try:
            await asyncio.wait_for(stop_event.wait(), timeout=float(interval_seconds))
            break
        except asyncio.TimeoutError:
            pass
```

**Step 8: Run Python tests**

```bash
pytest tests/marketplace/test_probe_auto_remove.py -v
```

Expected: PASS

**Step 9: Run full test suite to verify no regressions**

```bash
cd frontend && npm run test 2>&1 | tail -20
pytest tests/ -x -q 2>&1 | tail -20
```

**Step 10: Commit and PR**

```bash
git checkout -b feat/290-phase5b-auto-removal
git add frontend/src/app/api/internal/marketplace/auto-remove/ \
        frontend/src/__tests__/api/internal/marketplace/auto-remove.test.ts \
        llmhosts/marketplace/probes.py \
        tests/marketplace/test_probe_auto_remove.py
git commit -m "feat(marketplace): Phase 5B auto-removal of low-reputation nodes

- POST /api/internal/marketplace/auto-remove: internal route that sets
  marketplaceEnabled=false for device IDs passed by probe loop
- _check_and_remove_low_reputation(): after each probe cycle, computes
  30-day uptime and removes nodes below REPUTATION_REMOVAL_THRESHOLD (30%)
- run_probe_loop() extended with control_plane_url + internal_secret params
- 3 frontend unit tests + 2 Python tests

Closes #290 (Phase 5B auto-removal AC)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
gh pr create --title "feat(marketplace): Phase 5B auto-removal of low-reputation nodes" \
  --body "Closes #290 (Phase 5B AC: auto-removal from marketplace when score drops below threshold)"
```

---

## Task 3: Update #402 handoff doc

**Files:**
- Modify: `docs/SSOT-AND-HANDOFF.md` (update Phase 5 status section)

**Step 1: Update the handoff doc to reflect current state**

Find the Phase 5 section and update it to show what's complete. Add entries for PRs #421 (E2E CI smoke), a11y fixes, auto-removal.

**Step 2: Comment on GitHub issue #402 with summary and close it**

```bash
gh issue comment 402 --body "Handoff doc updated to reflect state as of 2026-03-08 (session 051). Phase 5 marketplace is functionally complete: probes, billing, reputation, auto-removal, Stripe Connect, operator/buyer dashboards. Hardware E2E (#359-363) remains human-gated."
gh issue close 402 --comment "Closing: SSOT updated."
```

---

## Task 4: Soft launch prep for #134

**Files:**
- Create: `docs/launch/soft-launch-announcement.md`
- Create: `docs/launch/monitoring-checklist.md`

**Step 1: Draft launch announcement**

Write a launch announcement doc covering:
- What LLMHosts does (one paragraph)
- HN Show HN post
- Reddit r/LocalLLaMA post
- Discord template
- GitHub Discussions setup for support

**Step 2: Create monitoring checklist**

Document the URLs to watch during launch:
- `https://llmhosts.com/api/health`
- Prometheus metrics endpoint
- Grafana dashboard
- GitHub Issues for incoming reports

**Step 3: Comment on #134 with what's been done and what remains human-gated**

```bash
gh issue comment 134 --body "Launch prep complete:
- Announcement drafts: docs/launch/soft-launch-announcement.md
- Monitoring checklist: docs/launch/monitoring-checklist.md
- GitHub Discussions enabled for support channel

Remaining (human decision required):
- [ ] Choose launch date
- [ ] Select initial channels (personal network, HN, r/LocalLLaMA, Discord)
- [ ] Who monitors dashboards during first 48h
"
```

---

## Task 5: Human-gated issues — add context comments, no action

For #359, #360, #361, #362, #363, #319:

```bash
gh issue comment 319 --body "E2E Master Plan tracking issue. Hardware runs (#359-363) require physical GPU access. Software test suite: 2,874 tests (backend + frontend + E2E). CI: 17-check pipeline on every PR. Ready for hardware runs when hardware is available."
```

Leave these open — they're correctly blocked on hardware.

---

## Completion Checklist

- [ ] Task 1: a11y PR merged
- [ ] Task 2: auto-removal PR merged, #290 Phase 5B AC checked
- [ ] Task 3: #402 closed
- [ ] Task 4: #134 launch prep added
- [ ] Task 5: #319 updated with context
- [ ] MEMORY.md updated with new state
