---
phase: 12-bulk-sweep-batch-review-queue
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - internal/store/migrations/0010_tag_suggestions.sql
  - internal/tagsweep/store.go
  - internal/tagsweep/store_test.go
  - internal/tagsweep/job.go
  - internal/tagsweep/job_test.go
  - internal/server/handlers_tagsweep.go
  - internal/server/handlers_tagsweep_test.go
  - internal/server/router.go
  - cmd/okf-workspace/main.go
autonomous: true
requirements: [TAG-05]
must_haves:
  truths:
    - "Starting a sweep enqueues one KindTagSuggest job per target page and returns immediately, writing nothing to disk"
    - "Draining the sweep jobs stages pending suggestion rows but performs NO frontmatter write and NO commit"
    - "Killing and restarting the worker re-runs pending KindTagSuggest jobs and still never auto-writes tags"
    - "The sweep targets untagged pages by default (absent from page_tags ∩ live pages) and all live pages when the all flag is set"
    - "The sweep-start and review-queue endpoints are admin-only (RequireRole(admin), session role)"
  artifacts:
    - path: "internal/store/migrations/0010_tag_suggestions.sql"
      provides: "tag_suggestions staging table (operational data, rebuildable, never source of truth)"
      contains: "CREATE TABLE IF NOT EXISTS tag_suggestions"
    - path: "internal/tagsweep/store.go"
      provides: "tag_suggestions persistence: StagePending, ListPending, and the untagged/all target enumeration"
      min_lines: 80
    - path: "internal/tagsweep/job.go"
      provides: "KindTagSuggest job kind + payload + handler that calls agent.SuggestTags and stages a pending row (NO write/commit)"
      contains: "KindTagSuggest"
    - path: "internal/server/handlers_tagsweep.go"
      provides: "handleStartTagSweep (202, enqueues per-page jobs, writes nothing) + handleListTagSuggestions (pending queue read)"
      min_lines: 60
  key_links:
    - from: "internal/server/handlers_tagsweep.go"
      to: "internal/tagsweep/job.go"
      via: "enqueue one KindTagSuggest job per target page via worker.Enqueue (fire-and-forget, never EnqueueAndWait)"
      pattern: "Enqueue.*KindTagSuggest"
    - from: "internal/tagsweep/job.go"
      to: "internal/agent/suggesttags.go"
      via: "the job handler calls agent.SuggestTags(path) and stages the result; it never calls pages.Save"
      pattern: "SuggestTags"
    - from: "cmd/okf-workspace/main.go"
      to: "internal/tagsweep/job.go"
      via: "worker.Register(tagsweep.KindTagSuggest, tagsweep.SuggestHandler(...)) on the EXISTING single worker"
      pattern: "Register.*KindTagSuggest"
---

<objective>
Build the backend fan-out + staging spine for the bulk tagging sweep (TAG-05): a new `KindTagSuggest` job on the EXISTING single-drain worker that calls Phase-11 `agent.SuggestTags` for ONE page and stages the result into a new `tag_suggestions` table with status `pending` — the job WRITES NO frontmatter and triggers NO commit. Plus the admin sweep-start endpoint (enumerates target pages, enqueues one job per page, returns 202 immediately, writing nothing) and the admin review-queue read endpoint (lists pages with pending suggestions). This is the load-bearing safety half of the phase: the sweep only PRODUCES pending rows; a write happens ONLY via a human-approved apply (Wave 2).

Purpose: TAG-05 — admin bulk sweep → per-page jobs → pending queue, writing nothing automatically. Serial drain of the existing worker is the natural LLM rate-limiter (per CONTEXT + Pitfall 6); do NOT add a parallel LLM caller.
Output: migration 0010, `internal/tagsweep` package (store + job), two admin endpoints, router + main.go wiring.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/12-bulk-sweep-batch-review-queue/12-CONTEXT.md
@.planning/research/PITFALLS.md
@.planning/research/ARCHITECTURE.md
@.planning/phases/11-per-page-llm-tag-suggestion/11-02-SUMMARY.md
</context>

<artifacts_this_phase_produces>
This plan delivers the Wave-1 backend artifacts of Phase 12:
- migration `0010_tag_suggestions.sql` — the `tag_suggestions` staging table (operational/derived; never content; rebuildable by re-running a sweep)
- `internal/tagsweep` package — `tag_suggestions` persistence + the `KindTagSuggest` job kind/payload/handler + target enumeration
- `POST /api/v1/admin/tags/sweep` (admin) — enqueue per-page jobs, return 202, write nothing
- `GET /api/v1/admin/tags/suggestions` (admin) — list pages with pending suggestions
(Wave 2 adds the batched approve endpoint; Wave 3 adds the review route UI + nav + sweep-start control.)
</artifacts_this_phase_produces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: tag_suggestions migration + tagsweep store (staging + target enumeration)</name>
  <files>internal/store/migrations/0010_tag_suggestions.sql, internal/tagsweep/store.go, internal/tagsweep/store_test.go</files>
  <read_first>
    - internal/store/migrations/0009_graph.sql — the migration tone + "operational/derived, files-are-truth, never source of truth" header comment to mirror; this is the latest migration so the new file is 0010.
    - internal/store/migrations.go — the embedded-migration loader (numbered NNNN_name.sql, idempotent via schema_migrations). No code change here; confirms the file naming/ordering contract.
    - internal/graph/query.go (allPages ~206, livePages ~243) — the EXACT untagged-detection primitives: untagged = livePages() (repo Tree, skip dirs/non-.md/trashed) MINUS the distinct page_path set in page_tags. Mirror this set-difference; do NOT re-implement page enumeration differently.
    - internal/graph/store.go (OpenStore ~51, SetRepo ~56) — the Store-over-shared-*sql.DB + SetRepo pattern to mirror for the tagsweep store.
  </read_first>
  <behavior>
    - Migration applies idempotently: a second Migrate() is a no-op (schema_migrations records version 10).
    - StagePending inserts a pending row (page_path, tags JSON with per-tag existing flags, base_revision, status='pending', created_at) and is callable repeatedly; re-staging the same page replaces/supersedes its prior pending row for that page (no duplicate pending rows per page — last suggestion wins).
    - ListPending returns one entry per page that has a pending row, carrying the staged tags + existing flags + base_revision, ordered deterministically (e.g. by page_path).
    - Targets(ctx, allPages bool): when allPages=false returns live pages absent from page_tags (untagged); when allPages=true returns all live pages. Both intersect the live-page set (a page in page_tags but no longer on disk is never targeted).
    - Targets with no untagged pages returns an empty slice (drives the "zero targets" UX in Wave 3) — not an error.
  </behavior>
  <action>
    Create `internal/store/migrations/0010_tag_suggestions.sql` defining `CREATE TABLE IF NOT EXISTS tag_suggestions` with columns: `id INTEGER PRIMARY KEY AUTOINCREMENT`, `page_path TEXT NOT NULL`, `suggestions TEXT NOT NULL` (JSON array of {tag, existing} objects — the staged proposal), `base_revision TEXT NOT NULL` (captured at suggestion time), `status TEXT NOT NULL DEFAULT 'pending'` (lifecycle: pending → resolved), `created_at TEXT NOT NULL DEFAULT (datetime('now'))`. Add a UNIQUE index on `(page_path) WHERE status='pending'` (a partial unique index) so a page has at most ONE pending row — re-staging supersedes. Add an index on `status` for the pending list query. Write a header comment in the 0009 tone: operational/derived staging data ONLY, NOT page content, never the source of truth — re-running a sweep reproduces the rows; a tag is NEVER written to a file from this table without an explicit human approve.

    Create `internal/tagsweep/store.go` — package `tagsweep`. Define a `Store` over the shared `*sql.DB` (mirror `graph.OpenStore`) plus a `SetRepo(*repo.Repo)` for live-page enumeration. Define a `Suggestion` struct ({Tag string, Existing bool}) and a `PendingEntry` struct ({PagePath string, Suggestions []Suggestion, BaseRevision string}). Implement:
    - `OpenStore(db *sql.DB) *Store`, `SetRepo(r *repo.Repo)`.
    - `StagePending(ctx, pagePath string, sugg []Suggestion, baseRevision string) error`: in one tx, DELETE any existing pending row for page_path, then INSERT the new pending row with `suggestions` = json.Marshal(sugg). (Supersede-on-restage.)
    - `ListPending(ctx) ([]PendingEntry, error)`: SELECT page_path, suggestions, base_revision WHERE status='pending' ORDER BY page_path; json.Unmarshal each `suggestions`.
    - `Targets(ctx, allPages bool) ([]string, error)`: build the live-page set from `s.repo.Tree()` applying the SAME skip rules graph.livePages uses (skip IsDir, non-.md, trashed); if allPages return the sorted live set; else SELECT DISTINCT page_path FROM page_tags into a set and return live MINUS tagged, sorted. A nil repo returns an empty slice (no panic).
    Reject a page_path that fails the existing `validIdentifier` style only at the HTTP layer (Task 3) — the store trusts its server caller; document this.

    Create `internal/tagsweep/store_test.go` — open a real in-memory or temp SQLite store via the store package's test helper (mirror internal/store/store_test.go or graph/store_test.go), run Migrate, then assert: migration idempotency (Migrate twice → no error, version recorded once); StagePending then ListPending round-trips the suggestions + flags + base_revision; re-StagePending the same page yields exactly one pending row (supersede); Targets(false) returns live-minus-tagged and Targets(true) returns all live pages; Targets(false) with everything tagged returns an empty slice.
  </action>
  <verify>
    <automated>CGO_ENABLED=0 go test ./internal/tagsweep/ ./internal/store/ -run 'Test' && CGO_ENABLED=0 go build ./...</automated>
  </verify>
  <done>migration 0010 applies idempotently; tagsweep.Store stages/lists pending rows (one per page, supersede-on-restage) and enumerates untagged-or-all targets against the live page set; `go build ./...` is clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: KindTagSuggest job (calls SuggestTags, stages pending, NO write/commit) + key-free safety test</name>
  <files>internal/tagsweep/job.go, internal/tagsweep/job_test.go</files>
  <read_first>
    - internal/graph/graphjob.go (KindGraph ~19, graphPayload ~24, RebuildPayload ~31, GraphHandler ~55) — the EXACT job-kind + JSON payload + `jobs.Handler` constructor + `defer recover()` pattern to mirror (a panic on one page becomes a returned error so the single drain survives). Mirror this shape exactly.
    - internal/jobs/worker.go (Handler type, Register, the single drain loop) + internal/jobs/queue.go (Enqueue/EnqueueJob, retry/backoff, markDone/markRetry) — confirms: one job runs at a time (serial = LLM rate-limit), failures retry-with-backoff then fail terminally, and the handler signature `func(ctx, payload string) error`.
    - internal/agent/suggesttags.go (SuggestTags ~266 signature: `SuggestTags(ctx, path) (tags []string, existing []bool, baseRev string, err error)`; ErrTagsInvalid ~48; ErrAgentDisabled) — the Phase-11 mode the job REUSES verbatim. The job calls SuggestTags and stages (tags+existing+baseRev); it does NOT re-implement suggestion.
    - .planning/research/PITFALLS.md (Pitfall 5 ~89, Pitfall 6 ~108) — the go/no-go safety invariant: the sweep stages pending suggestions ONLY; no tag is ever written without an explicit human approve, even across worker kill/restart. This is the phase's load-bearing guarantee and needs an explicit test.
    - .planning/phases/11-per-page-llm-tag-suggestion/11-02-SUMMARY.md — confirms SuggestTags is key-free testable at the agent seam with a fake model; the job test must be key-free too (inject a fake suggester, never a real LLM).
  </read_first>
  <behavior>
    - SuggestHandler returns a jobs.Handler; given a payload naming one page, it calls the injected suggester for that page and StagePendings the (tag, existing) pairs + base_revision. It NEVER calls pages.Save, never writes a file, never enqueues a commit.
    - A suggester error (e.g. ErrTagsInvalid after retries, or a transient model error) is RETURNED so the worker applies its retry/backoff — it does NOT stage a partial/garbage row and does NOT swallow the error.
    - A panic inside the handler is recovered and returned as an error (the single drain goroutine survives — mirrors GraphHandler).
    - SAFETY (go/no-go, Pitfall 5): draining N KindTagSuggest jobs produces N pending rows and ZERO frontmatter writes / ZERO commits. Re-running a still-pending job (kill+restart simulation) re-stages but still never writes.
  </action>
  <action>
    Create `internal/tagsweep/job.go` — define `const KindTagSuggest = "tag_suggest"` and a `suggestPayload{ Path string }` with a `SuggestPayload(path string) string` builder (json.Marshal, mirror graph.UpsertPagePayload). Define a narrow `suggester` interface satisfied structurally by `*agent.Service`: `SuggestTags(ctx context.Context, path string) (tags []string, existing []bool, baseRev string, err error)` — so `internal/tagsweep` imports `internal/agent` only for the sentinel errors (or not at all if the interface fully decouples it; prefer the narrow interface, no hard agent import, mirroring how agent declared `vocabularyReader` for graph). Implement `SuggestHandler(store *Store, s suggester) jobs.Handler` returning `func(ctx, payload string) (err error)` with `defer recover()` → returned error (mirror GraphHandler verbatim): unmarshal the payload, call `s.SuggestTags(ctx, p.Path)`, on error RETURN it (worker retries), else zip tags+existing into `[]Suggestion` and call `store.StagePending(ctx, p.Path, sugg, baseRev)`, returning its error. The handler NEVER references pages.Save / okf.SetTags / a commit — staging only.

    Create `internal/tagsweep/job_test.go` (KEY-FREE — inject a fake suggester struct, never a real model, env unset). Tests:
    - happy path: a fake suggester returning a fixed (tags, existing, baseRev) → handler stages exactly one pending row whose ListPending entry matches; assert NO file write occurred (the test never wires pages.Save; assert the store has the row and nothing else changed).
    - suggester error → handler RETURNS the error (so the worker would retry); assert NO pending row was staged for that page.
    - panic-in-suggester → handler returns a non-nil error (recover), does not crash the test.
    - SAFETY GATE (Pitfall 5, the go/no-go): wire a real `jobs.Worker` over a temp store, Register(KindTagSuggest, SuggestHandler(store, fakeSuggester)), enqueue jobs for several pages, Start, drain to completion (WaitForJob or poll ListPending), then assert: (a) ListPending has exactly one row per enqueued page, AND (b) NO commit / NO frontmatter write happened — prove this by asserting the test's content repo / git head is byte-identical to before the drain (the handler holds no writer), e.g. the fake suggester records that pages.Save was never invoked and a sentinel content file is unchanged. Then Stop the worker, re-enqueue the same pages (kill+restart simulation), Start again, drain, and re-assert NO write happened — only re-staging.
  </action>
  <verify>
    <automated>env -u DEEPSEEK_API_KEY -u OKF_LLM_API_KEY CGO_ENABLED=0 go test ./internal/tagsweep/ -run 'Test' -v && grep -v '^[[:space:]]*//' internal/tagsweep/job.go | grep -c 'pages\.Save\|okf\.SetTags\|KindCommit' | grep -qx 0 && echo SAFE</automated>
  </verify>
  <done>The KindTagSuggest handler calls SuggestTags and stages a pending row, never writing a file or commit; the key-free SAFETY GATE test proves draining + kill/restart produces only pending rows with ZERO writes; `internal/tagsweep/job.go` contains no pages.Save/okf.SetTags/KindCommit reference.</done>
</task>

<task type="auto">
  <name>Task 3: admin sweep-start + review-queue endpoints + worker/router/main wiring</name>
  <files>internal/server/handlers_tagsweep.go, internal/server/handlers_tagsweep_test.go, internal/server/router.go, cmd/okf-workspace/main.go</files>
  <read_first>
    - internal/server/handlers_search.go (handleReindex ~63, handleGraphReindex ~92, searchEnqueuer/graphEnqueuer interfaces ~28, the generic hidden-Git copy constants ~18) — the EXACT admin-action pattern: an enqueuer interface (Enqueue only), enqueue fire-and-forget, audit with a hidden-infra label, return 202 {"ok":true}. The sweep-start clones this but enqueues ONE job PER target page and returns the queued count.
    - internal/server/router.go (the admin subgroup ~263 under `admin.Use(auth.RequireRole(auth.RoleAdmin))`, the authHandlers struct fields ~87, Deps ~24) — where the two new admin routes mount; how a new dependency (the tagsweep store + an enqueuer) is added to authHandlers + Deps. Mirror the searchJobs/graphJobs/graph fields.
    - internal/server/handlers_agent.go (validIdentifier ~63, handleApplyTags ~710 for the request-decode + actorUsername/actorRole + writeError/writeJSON conventions, audit.Event usage) — the input-validation + audit + generic-error conventions to reuse.
    - cmd/okf-workspace/main.go (worker construction ~198, the Register(...) block ~202-258, the Deps wiring ~390 SearchJobs/GraphJobs/Graph) — register tagsweep.KindTagSuggest on the SAME worker; construct the tagsweep store over st.DB() + SetRepo(contentRepo); pass it + the worker into server.Deps. The job's suggester is the SAME agentSvc already constructed (~310).
    - internal/audit/audit.go (the Action constants ~29-60) — add a new ActionTagSweep constant (mirror ActionGraphReindex/ActionSearchReindex); audit the sweep start (non-secret detail: scope=untagged|all + count).
  </read_first>
  <action>
    Add `ActionTagSweep = "tag_sweep"` to `internal/audit/audit.go` (beside ActionGraphReindex).

    Create `internal/server/handlers_tagsweep.go`:
    - A generic hidden-infra copy constant for failures (e.g. `tagSweepUnavailable = "Tag suggestions are unavailable. Try again in a moment."`) — names NO job/queue/worker/LLM (hidden-infra rule).
    - A `tagSweepEnqueuer` interface (`Enqueue(ctx, kind, payload string) error`) mirroring graphEnqueuer.
    - `handleStartTagSweep` (admin subgroup): decode `{ all bool }` (default false = untagged-only); fail closed 500 if the tagsweep store or enqueuer is nil; call `store.Targets(ctx, req.All)`; for each target page enqueue ONE `tagsweep.KindTagSuggest` job via `Enqueue(ctx, tagsweep.KindTagSuggest, tagsweep.SuggestPayload(path))` (fire-and-forget — NEVER EnqueueAndWait, the CR-01 lesson); on any enqueue error log + return the generic 500 (best-effort: stop on first error). Audit `ActionTagSweep` with detail `scope=untagged|all count=N` (non-secret). Return 202 `{"ok":true,"queued":N}` — the request writes NOTHING to disk. Zero targets → 202 with queued=0 (drives the "every page already has tags" UX).
    - `handleListTagSuggestions` (admin subgroup): fail closed 500 if the store is nil; call `store.ListPending(ctx)`; return 200 JSON array of `{page_path, suggestions:[{tag,existing}], base_revision}` (the review-queue read; titles/paths are returned as data — the SPA renders them as text children, never HTML).

    Wire dependencies: add a `tagSuggestions *tagsweep.Store` field + a `tagSweepJobs tagSweepEnqueuer` field to `authHandlers`; add `TagSuggestions *tagsweep.Store` + `TagSweepJobs tagSweepEnqueuer` to `server.Deps` (with the same optional-nil-returns-500 doc as Graph/GraphJobs). In `router.go` admin subgroup add `admin.Post("/admin/tags/sweep", h.handleStartTagSweep)` and `admin.Get("/admin/tags/suggestions", h.handleListTagSuggestions)` (both inherit RequireRole(admin) + nosurf CSRF; comment that RBAC is read from the SESSION role, never client input — admin starts AND reviews per CONTEXT).

    In `cmd/okf-workspace/main.go`: after the graphStore Register block, construct `tagsweepStore := tagsweep.OpenStore(st.DB()); tagsweepStore.SetRepo(contentRepo)` and `worker.Register(tagsweep.KindTagSuggest, tagsweep.SuggestHandler(tagsweepStore, agentSvc))` (the SAME single worker; the suggester is the already-constructed agentSvc). Note: register BEFORE worker.Start, like every other kind. In the server.Deps literal add `TagSuggestions: tagsweepStore, TagSweepJobs: worker` (the SAME worker passed as SearchJobs/GraphJobs).

    Create `internal/server/handlers_tagsweep_test.go` (real HTTP seam; KEY-FREE — no agent round-trip needed since the endpoints enqueue/list, they do not call the model): use a fake enqueuer that records (kind, payload) pairs and a real tagsweep.Store over a temp DB. Tests:
    - `TestStartTagSweepAdminOnly`: a non-admin (reader/editor) session → 403 (RequireRole(admin) gate); an admin → 202.
    - `TestStartTagSweepEnqueuesPerPage`: seed page_tags + live pages so Targets(false) returns K untagged pages; POST {all:false} → 202 {queued:K} and the fake enqueuer recorded EXACTLY K KindTagSuggest jobs with the correct per-page payloads; assert NO write/commit occurred (the handler only enqueues).
    - `TestStartTagSweepAllScope`: {all:true} enqueues one job per live page (tagged + untagged).
    - `TestStartTagSweepZeroTargets`: everything tagged + {all:false} → 202 {queued:0}, zero jobs enqueued.
    - `TestListTagSuggestionsAdminOnly`: non-admin → 403; admin → 200.
    - `TestListTagSuggestionsReturnsPending`: stage two pending rows via the store, GET → 200 with both entries (page_path + suggestions + base_revision) in deterministic order.
  </action>
  <verify>
    <automated>CGO_ENABLED=0 go build ./... && env -u DEEPSEEK_API_KEY -u OKF_LLM_API_KEY CGO_ENABLED=0 go test ./internal/server/ ./internal/tagsweep/ ./internal/audit/ -run 'TagSweep|TagSuggestions|Test' && go vet ./internal/server/ ./internal/tagsweep/</automated>
  </verify>
  <done>POST /admin/tags/sweep enqueues one KindTagSuggest job per untagged-or-all target and returns 202 {queued:N} writing nothing; GET /admin/tags/suggestions lists pending rows; both are admin-only (403 for non-admin); the worker registers KindTagSuggest on the existing single drain; `go build ./...` + `go vet` clean.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client → admin sweep/review endpoints | untrusted request body (`all` flag, page_path values surfaced in the queue) crosses into the server |
| LLM reply → tag_suggestions row | the model's suggested tags (already validated/normalized by Phase-11 SuggestTags) are staged as data; they are NOT written to a file in this plan |
| worker drain → SQLite | the single drain stages pending rows; no file/Git write happens here |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-12-01 | Elevation of Privilege | sweep-start + review-queue endpoints | mitigate | both mount under `admin.Use(auth.RequireRole(auth.RoleAdmin))`; role read from the SESSION, never the request body (asserted by TestStartTagSweepAdminOnly / TestListTagSuggestionsAdminOnly) |
| T-12-02 | Tampering | the `all` flag / a forged page_path | mitigate | sweep targets are computed SERVER-SIDE from the live-page set ∩ page_tags (the client cannot inject arbitrary paths into the queue); page_path values that reach the store come only from `repo.Tree()` enumeration, not the client |
| T-12-03 | Denial of Service | sweep enqueues many jobs | accept | serial single-drain is the natural rate-limiter (Pitfall 6); the worker's backoff/retry/MaxAttempts bounds a flaky LLM; 5-user internal tool, admin-only trigger — low-value target |
| T-12-04 | Information Disclosure | hidden-infra leakage in copy/errors | mitigate | the generic `tagSweepUnavailable` copy + audit labels name no job/queue/worker/LLM/git/sqlite vocabulary (hidden-Git rule) |
| T-12-05 | Repudiation | who started a workspace-wide sweep | mitigate | `ActionTagSweep` audit event records actor + scope + count (non-secret detail) |
| T-12-SC | Tampering | npm/pip/cargo installs | mitigate | NO new dependency is added this plan (CONTEXT lock); slopcheck N/A — nothing to install |
</threat_model>

<verification>
- `CGO_ENABLED=0 go build ./...` is clean (single CGO-free binary preserved; no new dependency).
- `env -u DEEPSEEK_API_KEY -u OKF_LLM_API_KEY CGO_ENABLED=0 go test ./internal/tagsweep/ ./internal/server/ ./internal/store/ ./internal/audit/` passes KEY-FREE (the job + endpoints prove out with a fake suggester / fake enqueuer — no real LLM).
- The SAFETY GATE (Pitfall 5 go/no-go) test in `internal/tagsweep/job_test.go` proves draining the sweep jobs stages pending rows with ZERO frontmatter writes / ZERO commits, and that kill+restart re-stages but never auto-writes.
- migration 0010 applies idempotently over the existing 0001–0009 chain.
- `go vet ./internal/tagsweep/ ./internal/server/` clean.
</verification>

<success_criteria>
- Migration `0010_tag_suggestions.sql` exists and creates the `tag_suggestions` staging table (operational data, never source of truth) with a partial-unique pending index.
- `internal/tagsweep` package: a Store (stage/list pending + untagged-or-all target enumeration against the live page set) and the `KindTagSuggest` job (calls Phase-11 SuggestTags, stages a pending row, NO write/commit).
- `POST /api/v1/admin/tags/sweep` (admin) enqueues one job per target and returns 202 {queued:N}, writing nothing.
- `GET /api/v1/admin/tags/suggestions` (admin) lists pages with pending suggestions.
- KindTagSuggest registered on the EXISTING single drain worker in main.go; the suggester is the already-constructed agentSvc.
- The go/no-go safety invariant (Pitfall 5) is proven by an explicit key-free test.
</success_criteria>

<output>
Create `.planning/phases/12-bulk-sweep-batch-review-queue/12-01-SUMMARY.md` when done.
</output>