---
phase: 12-bulk-sweep-batch-review-queue
plan: 02
type: execute
wave: 2
depends_on: ["12-01"]
files_modified:
  - internal/tagsweep/store.go
  - internal/tagsweep/store_test.go
  - internal/pages/batch.go
  - internal/pages/batch_test.go
  - internal/server/handlers_tagsweep.go
  - internal/server/handlers_tagsweep_test.go
  - internal/server/router.go
autonomous: true
requirements: [TAG-06]
must_haves:
  truths:
    - "Approving N pages routes each page's approved tags through the Phase-11 byte-stable apply (okf.SetTags → single-writer path), re-validated/normalized server-side"
    - "Approving N pages produces BATCHED commits (not one commit per page)"
    - "A per-page stale base_revision 409s that page individually WITHOUT failing the rest of the batch"
    - "Approved pages' staged rows are marked resolved; the queue shrinks"
    - "The batched approve endpoint is admin-only and never trusts the client tag list"
  artifacts:
    - path: "internal/pages/batch.go"
      provides: "A batched single-writer apply: apply tags to many pages in ONE (or few) commit(s) via the existing single-writer path, returning per-page outcomes (applied/stale/notfound)"
      min_lines: 60
    - path: "internal/server/handlers_tagsweep.go"
      provides: "handleApproveTagSuggestions — batched approve: server re-validates per page, applies via the batch path, marks rows resolved, returns per-page results"
      min_lines: 50
  key_links:
    - from: "internal/server/handlers_tagsweep.go"
      to: "internal/pages/batch.go"
      via: "the approve handler calls the batched apply (one commit for the batch), not a per-page Save loop"
      pattern: "Batch|ApplyTagsBatch"
    - from: "internal/pages/batch.go"
      to: "internal/okf/"
      via: "each page's tags written byte-stably via okf.SetTags through the single-writer commit path"
      pattern: "SetTags"
    - from: "internal/server/handlers_tagsweep.go"
      to: "internal/agent/suggesttags.go"
      via: "server re-validates/normalizes each page's tags via agent.ValidateTags before write (never trusts the client)"
      pattern: "ValidateTags"
---

<objective>
Build the batched approve path for the review queue (TAG-06 apply half): an admin-only endpoint that approves suggestions for one OR several pages, routing each page through the SAME Phase-11 byte-stable apply (server re-validates/normalizes → okf.SetTags → the single-writer commit) but committing in BATCHES — NOT one commit per page (Pitfall 6). A per-page stale `base_revision` 409s that page individually WITHOUT sinking the rest of the batch; resolved rows are marked so the queue shrinks. The client tag list is NEVER trusted — the server re-validates per page.

Purpose: TAG-06 — review queue approvals routed through the byte-stable apply path, committed in batches. The batched-commit invariant (Pitfall 6) is the load-bearing guarantee of this plan and gets an explicit test.
Output: a batched apply in `internal/pages`, the `tag_suggestions` resolve transition in `internal/tagsweep`, and the admin approve endpoint + route.
</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/phases/11-per-page-llm-tag-suggestion/11-02-SUMMARY.md
@.planning/phases/12-bulk-sweep-batch-review-queue/12-01-PLAN.md
</context>

<artifacts_this_phase_produces>
This plan delivers the Wave-2 batched-apply artifacts of Phase 12:
- `internal/pages/batch.go` — a batched single-writer tag apply (many pages → one/few commits), returning per-page outcomes (applied / stale-409 / not-found) so one stale page never fails the batch
- the `tag_suggestions` resolve transition in `internal/tagsweep` (pending → resolved on approve/reject)
- `POST /api/v1/admin/tags/approve` (admin) — batched approve that re-validates per page, applies via the batch path, marks rows resolved, returns per-page results
(Wave 3 consumes this endpoint from the review route UI.)
</artifacts_this_phase_produces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: batched single-writer tag apply + tag_suggestions resolve transition</name>
  <files>internal/pages/batch.go, internal/pages/batch_test.go, internal/tagsweep/store.go, internal/tagsweep/store_test.go</files>
  <read_first>
    - internal/server/handlers_agent.go (handleApplyTags ~710, setTagsFrontmatter ~812) — the EXACT per-page byte-stable apply this plan generalizes to a batch: ValidateTags(nil vocab) → setTagsFrontmatter (okf.Parse(AssembleSource) → okf.SetTags → Emit → re-Parse → RawFront) → pages.Save(baseRevision) → 409 on ErrStaleRevision. The batch reuses this same write, just grouped into one commit.
    - internal/pages/*.go — the pages.Service.Save signature + the single-writer commit path (pages.KindCommit + CommitHandler). Find how Save assembles + enqueues/commits; the batch must commit MULTIPLE pages' tag writes in ONE commit (or a small number), NOT call Save once per page (which would produce N commits — the Pitfall-6 anti-pattern). Read how the commit job batches/serializes; if Save is strictly one-commit-per-call, add a batch entrypoint on the Service that stages all page writes then commits once via the single-writer path.
    - internal/pages/commit*.go (the CommitHandler + CommitSpec/payload shape) — to understand how to express "commit these N file changes as one commit" on the single-writer worker. The batch path must use the SAME single writer (no second concurrent writer — Pitfall 6).
    - internal/agent/suggesttags.go (ValidateTags ~334) — the server-side re-validate/normalize the batch reuses per page (nil vocab on write).
    - internal/pages errors (ErrPageNotFound, ErrStaleRevision) — the per-page outcomes the batch maps WITHOUT aborting the whole batch.
    - internal/tagsweep/store.go (from 12-01) — add the resolve transition here.
    - .planning/research/PITFALLS.md (Pitfall 6 ~108) — route writes through the SAME single-writer path, batch into one commit (not one-per-page), respect optimistic concurrency (a stale page 409s individually), idempotent/resumable.
  </read_first>
  <behavior>
    - ApplyTagsBatch takes a list of {page_path, tags (already server-re-validated), base_revision} items and returns a per-page result list {page_path, status: applied|stale|notfound} — it does NOT return a single all-or-nothing error.
    - All successfully-applied pages land in ONE commit (or a bounded few), NOT one commit per page — assert the commit count is < the applied-page count for N>1.
    - A page whose base_revision is stale (the file moved since suggestion) yields status=stale for THAT page and is excluded from the commit; the other pages still apply and commit. The batch never overwrites a moved page (no clobber).
    - A page that no longer exists yields status=notfound and is skipped; the batch continues.
    - Each applied page's frontmatter changes ONLY the tags lines (byte-stable, inherited from setTagsFrontmatter / okf.SetTags); body + other frontmatter untouched.
    - ResolvePending(page_paths) flips those pending rows to status='resolved' (called by the handler after a successful apply or an explicit reject) so ListPending no longer returns them.
  </behavior>
  <action>
    Add to `internal/tagsweep/store.go`: `ResolvePending(ctx, pagePaths []string) error` — UPDATE tag_suggestions SET status='resolved' WHERE page_path IN (...) AND status='pending' (parameterized IN, no SQLi). Add `GetPending(ctx, pagePath string) (PendingEntry, bool, error)` if the handler needs to read a single page's staged base_revision/tags server-side (so the handler does not trust the client's claimed base_revision — it reads the STAGED base_revision from the queue). Extend `store_test.go`: ResolvePending flips only the named pending rows (others stay pending; ListPending shrinks); GetPending returns the staged row or (_, false, nil) when absent.

    Create `internal/pages/batch.go` — add a method on the pages `Service` (e.g. `ApplyTagsBatch(ctx, items []TagApplyItem, actor string) ([]TagApplyResult, error)` where `TagApplyItem{PagePath, Tags []string, BaseRevision string}` and `TagApplyResult{PagePath, Status string}` with Status one of "applied"/"stale"/"notfound"). Implementation: for each item, Get the page (notfound → record notfound, continue), build the new byte-stable frontmatter region via the SAME logic as `setTagsFrontmatter` (factor that helper into a reusable location if it currently lives in the server package — prefer moving the byte-stable region builder so BOTH the per-page handler AND the batch share ONE implementation and cannot drift; or call okf.SetTags directly here), then validate the staged base_revision against the current revision: if stale → record "stale", continue (no write). Collect all non-stale, non-notfound page writes and commit them through the SINGLE-WRITER path as ONE commit (use the existing CommitHandler/CommitSpec to express a multi-file commit; if the current commit path is strictly one-file-per-commit, extend the CommitSpec/payload to carry multiple file writes so the batch lands in one commit — keep it the SAME single drain worker, never a second writer). Return the per-page results. Do NOT abort the batch on a single stale/notfound page.

    Create `internal/pages/batch_test.go` (real git harness, mirror the pageFixture/real-git seam Phase-11 used): seed 3 pages, capture each base_revision, then:
    - BATCHED-COMMIT GATE (Pitfall 6): ApplyTagsBatch over all 3 with current revisions → all 3 status=applied, AND the git history grew by exactly ONE commit (count commits before/after; assert delta==1, NOT 3). Each page's body is byte-identical and only its tags lines changed.
    - STALE-WITHOUT-SINKING-THE-BATCH: mutate page 2 out-of-band (so its base_revision is stale), then ApplyTagsBatch over all 3 with the OLD revisions → page 2 status=stale (not written, not clobbered), pages 1 and 3 status=applied in ONE commit; the queue/file for page 2 is untouched.
    - NOTFOUND: include a path that does not exist → status=notfound, the rest still apply.
    - Idempotent/resumable: re-running the batch for already-applied pages is safe (re-applies the same tags byte-stably or no-ops; assert no corruption).
  </action>
  <verify>
    <automated>env -u DEEPSEEK_API_KEY -u OKF_LLM_API_KEY CGO_ENABLED=0 go test ./internal/pages/ ./internal/tagsweep/ -run 'Test' -v && CGO_ENABLED=0 go build ./...</automated>
  </verify>
  <done>ApplyTagsBatch applies many pages' tags byte-stably through the single-writer path in ONE commit (delta==1 for N>1), a stale page 409s individually without sinking the batch, notfound is skipped; ResolvePending shrinks the pending queue; the batched-commit gate test passes.</done>
</task>

<task type="auto">
  <name>Task 2: admin batched approve endpoint + route</name>
  <files>internal/server/handlers_tagsweep.go, internal/server/handlers_tagsweep_test.go, internal/server/router.go</files>
  <read_first>
    - internal/server/handlers_tagsweep.go (from 12-01) — the file the approve handler is added to; reuse its hidden-infra copy constant + the tagSweepUnavailable convention + the actorUsername/actorRole/audit helpers.
    - internal/server/handlers_agent.go (handleApplyTags ~710) — the validation discipline: reject NUL, cap list size (maxApplyTags), re-validate via agent.ValidateTags(nil vocab), map ErrStaleRevision→409 / ErrPageNotFound→404, audit ActionAgentPatchApproval with non-secret detail. The batched approve applies this PER PAGE but aggregates into one response.
    - internal/pages/batch.go (from Task 1) — the ApplyTagsBatch method the handler calls.
    - internal/tagsweep/store.go (ResolvePending, GetPending) — read the STAGED base_revision per page (the server does not trust the client's claimed base_revision); resolve approved rows after apply.
    - internal/server/router.go (admin subgroup ~263) — where POST /admin/tags/approve mounts (admin-only + nosurf CSRF).
    - internal/audit/audit.go (ActionAgentPatchApproval) — reuse for each approved page (or a single batch-approval audit event with count).
  </read_first>
  <action>
    Add `handleApproveTagSuggestions` to `internal/server/handlers_tagsweep.go` (admin subgroup): decode `{ approvals: [{ page_path, tags:[...] }] }` (the client sends the per-page CHECKED tags — the human's approval set). For each approval: validate page_path (validIdentifier), reject NUL in tags, cap len(tags) ≤ maxApplyTags, RE-VALIDATE/normalize via `agent.ValidateTags(tags, nil)` (drop the page with an empty-after-normalize result rather than 400-ing the whole batch — record it as skipped); read the STAGED base_revision from `tagsweep.GetPending(page_path)` (use the staged revision, NOT a client-supplied one — the queue is the server's record of what was suggested against which revision; if the page is no longer pending, skip it). Build the `[]TagApplyItem` from the re-validated tags + staged base_revisions, call `pages.ApplyTagsBatch(ctx, items, actor)`, then `tagsweep.ResolvePending(ctx, appliedPagePaths)` for the pages that returned status=applied (leave stale/notfound rows PENDING so the UI can show the stale state / re-run). Audit one `ActionAgentPatchApproval` (or `ActionTagSweep`) batch event with non-secret detail (count applied / stale / skipped — never the tags or page content). Return 200 with the per-page results array `[{page_path, status}]` so the UI can decrement the backlog for applied pages and switch a stale page into the inherited stale state. Use the generic hidden-infra copy on any infra failure.

    Extend `internal/server/handlers_tagsweep_test.go` (real git seam, KEY-FREE — no model call; the approve path never calls the LLM):
    - `TestApproveTagSuggestionsAdminOnly`: non-admin → 403; admin → 200.
    - `TestApproveBatchedOneCommit`: stage pending rows for 3 pages (via the store), POST approvals for all 3 → 200 all applied, git history grew by exactly ONE commit (delta==1, not 3), and ListPending now returns zero (rows resolved).
    - `TestApproveStaleDoesNotSinkBatch`: stage 3, mutate page 2 out-of-band, approve all 3 → page 2 status=stale and STAYS pending (for a re-run), pages 1+3 applied in one commit + resolved.
    - `TestApproveRevalidatesServerSide`: send a tampered/over-cap/garbage tag list for a page → the server normalizes/caps before write (assert the written tags are the cleaned set, proven by reading the page frontmatter), never the raw client list.
    - `TestApproveUsesStagedBaseRevision`: the handler ignores any client-supplied base_revision and uses the staged one (e.g. omit it from the request → still applies correctly against the staged revision).

    In `router.go` admin subgroup add `admin.Post("/admin/tags/approve", h.handleApproveTagSuggestions)` (RequireRole(admin) + nosurf CSRF; comment that the server re-validates and uses the staged base_revision — the client tag list is never trusted, mirroring handleApplyTags).
  </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/pages/ ./internal/tagsweep/ -run 'Approve|Batch|TagSweep|TagSuggestions|Test' && go vet ./internal/server/</automated>
  </verify>
  <done>POST /admin/tags/approve approves one or many pages through the batched single-writer apply (one commit for the batch), re-validates per page server-side, uses the STAGED base_revision (client list never trusted), 409s a stale page individually without sinking the batch, and resolves applied rows; admin-only (403 for non-admin); `go build ./...` + `go vet` clean.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client → admin approve endpoint | untrusted approval set (page_path + checked tags) crosses into the server; the client's claimed tags and any claimed revision are NOT trusted |
| batch apply → single-writer commit | many pages' tag writes funnel through the ONE existing single drain writer (no second concurrent writer) |
| staged base_revision → write | the optimistic-concurrency floor: a moved page must 409 individually, never clobber |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-12-06 | Tampering | client-supplied tag list | mitigate | every page's tags re-validated/normalized via `agent.ValidateTags(nil vocab)` before write (TestApproveRevalidatesServerSide); the raw client list is never written |
| T-12-07 | Tampering | client-supplied base_revision | mitigate | the handler reads the STAGED base_revision from the queue (GetPending), ignoring any client value (TestApproveUsesStagedBaseRevision); a moved page 409s |
| T-12-08 | Elevation of Privilege | approve endpoint | mitigate | mounts under `admin.Use(auth.RequireRole(auth.RoleAdmin))`; role from the SESSION (TestApproveTagSuggestionsAdminOnly) |
| T-12-09 | Denial of Service | single-writer contention vs interactive saves | mitigate | batched into ONE commit (not N), routed through the SAME single drain (Pitfall 6) so interactive saves are not starved by a commit-per-page storm |
| T-12-10 | Tampering | partial/half-applied batch on a stale/missing page | mitigate | per-page outcomes (applied/stale/notfound); a stale page is excluded without aborting; resumable + idempotent re-apply (TestApproveStaleDoesNotSinkBatch) |
| T-12-SC | Tampering | npm/pip/cargo installs | mitigate | NO new dependency this plan (CONTEXT lock); slopcheck N/A |
</threat_model>

<verification>
- `CGO_ENABLED=0 go build ./...` clean (CGO-free single binary, no new dependency).
- `env -u DEEPSEEK_API_KEY -u OKF_LLM_API_KEY CGO_ENABLED=0 go test ./internal/pages/ ./internal/tagsweep/ ./internal/server/` passes KEY-FREE (the approve path never calls the LLM).
- BATCHED-COMMIT GATE (Pitfall 6): `TestApproveBatchedOneCommit` / `TestApplyTagsBatch` prove N approved pages produce exactly ONE commit (history delta==1, not N).
- STALE-WITHOUT-SINKING-THE-BATCH: `TestApproveStaleDoesNotSinkBatch` proves a stale page 409s individually and stays pending while the rest commit.
- Byte-stability inherited from okf.SetTags/setTagsFrontmatter: each applied page's body + non-tags frontmatter are byte-identical (only tags lines change).
- `go vet ./internal/server/` clean.
</verification>

<success_criteria>
- `internal/pages/batch.go`: ApplyTagsBatch applies many pages' tags byte-stably through the single-writer path in ONE (or few) commit(s), returning per-page outcomes (applied/stale/notfound) so one stale/missing page never fails the batch.
- `internal/tagsweep` ResolvePending/GetPending: resolved rows leave the pending queue; the staged base_revision is the server's source of truth on apply.
- `POST /api/v1/admin/tags/approve` (admin) batches the approve, re-validates per page (client list never trusted), uses the staged base_revision, 409s a stale page individually, and resolves applied rows.
- The batched-commit invariant (Pitfall 6) is proven by an explicit history-delta test.
</success_criteria>

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