---
phase: 16-security-hardening
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [internal/tagsweep/job.go, config.example.yaml, internal/okf/repair.go, internal/server/handlers_agent.go, internal/server/handlers_tagsweep.go]
autonomous: true
requirements: [SEC-01, SEC-02, SEC-03]

must_haves:
  truths:
    - "Config setting for max concurrent sweep jobs (default 10)"
    - "Sweep enqueues jobs in batches up to cap; remaining queued"
    - "SetTags validates tag charset server-side: [a-zA-Z0-9_ -]"
    - "Invalid tags rejected with 400 Bad Request"
    - "govulncheck root cause identified and resolution documented"
  artifacts:
    - path: "config.example.yaml"
      provides: "sweep.max_concurrent_jobs config key"
    - path: "internal/okf/repair.go"
      provides: "SetTags charset validation"
---

<objective>
Three defense-in-depth security hardening items: cap sweep fan-out (SEC-01), validate tag charset server-side (SEC-02), resolve govulncheck issue (SEC-03).
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md §SEC-01, SEC-02, SEC-03
@.planning/phases/16-security-hardening/16-CONTEXT.md
</context>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: SEC-01 — Add configurable fan-out cap for admin sweep</name>
  <files>config.example.yaml, internal/tagsweep/job.go, internal/server/handlers_tagsweep.go</files>
  <read_first>
    - internal/tagsweep/job.go — SuggestHandler, how jobs are created
    - internal/server/handlers_tagsweep.go — handleStartTagSweep, the enqueue loop
    - config.example.yaml — current config structure
    - config.yaml — user's actual config
  </read_first>
  <behavior>
    Add a `sweep.max_concurrent_jobs` config key (default 10). The sweep-start handler reads this value and only enqueues up to that many jobs at once; remaining targets are internally re-enqueued as the current batch completes, or the handler returns a `queued` count showing how many were enqueued in this batch (remaining naturally handled by the worker drain).
    Simple approach: cap the for-loop in handleStartTagSweep at maxConcurrent. The remaining targets aren't lost — the admin can re-run (re-staging supersedes per the existing comment). If targets > cap, return 202 with `queued: cap, total: len(targets)` so the UI shows progress.
  </behavior>
  <action>
    1. Add to config struct: `SweepMaxConcurrentJobs int` with default 10
    2. Add to `config.example.yaml`: `sweep.max_concurrent_jobs: 10` with comment
    3. Modify `handleStartTagSweep`: cap the enqueue loop at maxConcurrent, return `{ok, queued, total}` with total count
    4. Add test for capped enqueue
  </action>
  <acceptance_criteria>
    - grep 'max_concurrent_jobs' config.example.yaml returns match
    - grep 'SweepMaxConcurrentJobs' internal/ returns match
    - go test ./internal/... exits 0
    - go build ./... exits 0
  </acceptance_criteria>
  <done>Sweep fan-out capped at configurable max, with total count in response.</done>
</task>

<task type="implement" tdd="true">
  <name>Task 2: SEC-02 — Add tag charset validation server-side</name>
  <files>internal/okf/repair.go, internal/server/handlers_agent.go, internal/server/handlers_tagsweep.go</files>
  <read_first>
    - internal/okf/repair.go — SetTags function
    - internal/server/handlers_agent.go — handleApplyTags (per-page apply)
    - internal/server/handlers_tagsweep.go — handleApproveTagSuggestions (bulk approve)
  </read_first>
  <behavior>
    Add a `ValidateTagCharset(tag string) error` function in internal/okf that rejects tags containing characters outside `[a-zA-Z0-9_ -]`. Call it:
    - In `SetTags` — rejects invalid tags before mutation
    - In the handlers that validate/apply tags (handleApplyTags, handleApproveTagSuggestions)
    Return 400 Bad Request with message "Invalid tag: ..." on rejection.
    Existing sink encoding (Markdown render, graph) unchanged — this is defense-in-depth.
  </behavior>
  <action>
    1. Add to `internal/okf/repair.go`:
    ```go
    var tagCharsetRE = regexp.MustCompile(`^[a-zA-Z0-9_ -]+$`)
    func ValidateTagCharset(tag string) error {
        if !tagCharsetRE.MatchString(tag) {
            return fmt.Errorf("invalid tag %q: only letters, numbers, underscores, dashes, and spaces are allowed", tag)
        }
        return nil
    }
    ```
    2. Call ValidateTagCharset in SetTags for each tag, return error on first invalid
    3. Call in handleApplyTags and handleApproveTagSuggestions during tag normalization
    4. Add test cases: valid tags, XSS attempt tags (<script>, <img>, onclick=), Unicode tags
  </action>
  <acceptance_criteria>
    - grep 'ValidateTagCharset' internal/okf/repair.go returns match
    - grep 'tagCharsetRE' internal/okf/repair.go returns match
    - grep '</script>' internal/okf/settags_test.go returns match (XSS test)
    - go test ./internal/okf/... exits 0
    - go test ./internal/server/... exits 0
    - go build ./... exits 0
  </acceptance_criteria>
  <done>Tag charset validation added server-side, XSS attempts rejected at the boundary.</done>
</task>

<task type="implement" tdd="false">
  <name>Task 3: SEC-03 — Resolve govulncheck issue</name>
  <read_first>
    - go.mod — Go version and dependencies
  </read_first>
  <behavior>
    Run `go run golang.org/x/vuln/cmd/govulncheck@latest ./...` and diagnose the failure.
    - If resolved by upgrading x/tools or Go → upgrade and verify clean
    - If still broken → document the exception, verify `go vet ./...` is clean, note the Go version bug
    Update CI workflow or document the scanning approach.
  </behavior>
  <action>
    1. Run `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`
    2. If it fails: check Go version (1.26), try upgrading x/tools
    3. If still fails: document that `go vet ./...` is clean, govulncheck has known Go 1.26 issue
    4. Run `go vet ./...` to confirm clean
  </action>
  <acceptance_criteria>
    - `go vet ./...` exits 0
    - govulncheck result documented (clean OR exception noted)
    - Root cause identified in SUMMARY.md
  </acceptance_criteria>
  <done>govulncheck triaged: root cause documented, go vet clean.</done>
</task>

</tasks>

<success_criteria>
- go build + go vet + go test all clean
- SEC-01: configurable cap, default 10
- SEC-02: tag charset validated, XSS tests pass
- SEC-03: root cause documented
</success_criteria>
