---
phase: 11-per-page-llm-tag-suggestion
plan: 02
type: execute
wave: 2
depends_on: ["11-01"]
files_modified:
  - internal/agent/agent.go
  - internal/agent/prompts.go
  - internal/agent/suggesttags.go
  - internal/agent/suggesttags_test.go
  - internal/server/handlers_agent.go
  - internal/server/handlers_tags_test.go
  - internal/server/router.go
  - cmd/okf-workspace/main.go
autonomous: true
requirements: [TAG-01, TAG-03]
must_haves:
  truths:
    - "Requesting tag suggestions for a page returns up to 5 normalized tags, each flagged existing-vs-new against the workspace vocabulary, plus the page's base revision — produced by a single-shot ChatModel.Generate mode (NOT a 6th agent tool)"
    - "The SuggestTags mode validates-and-retries the model output (array-of-strings, cap MAX 5, lowercase/trim/dedupe, reject garbage) and is fully testable WITHOUT an API key (fake model)"
    - "Applying approved tags is an editor-gated CSRF endpoint that re-validates+normalizes server-side, writes ONLY the tags lines byte-stably via okf.SetTags through pages.Save → the single-writer commit, and 409s on a moved revision instead of clobbering"
    - "The read-only 5-tool allow-list and its set-equality build gate are UNCHANGED (no 6th tool added)"
  artifacts:
    - path: "internal/agent/suggesttags.go"
      provides: "SuggestTags single-shot mode + validateTags (cap/normalize/dedupe/reject) + the MaxSuggestedTags constant"
      contains: "func (s *Service) SuggestTags"
    - path: "internal/agent/suggesttags_test.go"
      provides: "key-free fake-model test for SuggestTags + validateTags (cap, normalize, dedupe, garbage rejection, vocab bias in prompt)"
      contains: "func TestSuggestTags"
    - path: "internal/server/handlers_agent.go"
      provides: "handleSuggestTags (returns suggestions+base_revision+existing flags) + handleApplyTags (editor+CSRF, re-validate/normalize, okf.SetTags → pages.Save, 409 on stale)"
      contains: "func (h *authHandlers) handleApplyTags"
    - path: "internal/server/handlers_tags_test.go"
      provides: "apply-tags 409-stale gate test + server-side re-normalize/cap test"
      contains: "func TestApplyTagsStaleRevision"
  key_links:
    - from: "internal/agent/suggesttags.go"
      to: "internal/graph/query.go"
      via: "SuggestTags reads the workspace vocabulary via a narrow vocabularyReader dep (satisfied by *graph.Store.Vocabulary from 11-01) to bias the prompt"
      pattern: "Vocabulary\\(ctx"
    - from: "internal/server/handlers_agent.go"
      to: "internal/okf/repair.go"
      via: "handleApplyTags writes the approved tags byte-stably via okf.SetTags (11-01) then commits through pages.Save"
      pattern: "okf\\.SetTags"
    - from: "internal/server/handlers_agent.go"
      to: "internal/pages/service.go"
      via: "handleApplyTags reuses pages.Save(baseRevision) → single-writer commit; ErrStaleRevision → 409"
      pattern: "ErrStaleRevision"
---

<objective>
Build the backend half of per-page tag suggestion (TAG-01): a `SuggestTags` agent MODE (single-shot `ChatModel.Generate`, validate-and-retry, biased to the existing workspace vocabulary, capped at 5) plus the two HTTP endpoints — a read-side suggest endpoint and a write-side editor-gated CSRF apply endpoint that reuses the existing byte-stable single-writer commit path.

Purpose: This is the suggestion engine and the safety-gated write the approval UI (Wave 3) drives. It MULTIPLIES the two correct primitives 11-01 established — `okf.SetTags` (byte-stable tags write, TAG-03) and `Store.Vocabulary` (vocab biasing, TAG-04) — into a working suggest→approve→apply→commit flow. It mirrors the proven Rewrite/Draft single-shot + validate-and-retry pattern (provider-agnostic, NOT response_format) and the `/apply-patch` editor+CSRF→pages.Save→single-writer→409 pattern. SuggestTags is a MODE and apply is a NON-tool endpoint, so the read-only 5-tool boundary and its set-equality build gate stay UNCHANGED — no 6th tool.

Output: `SuggestTags` + `validateTags` in internal/agent/suggesttags.go with a key-free fake-model test; a vocabulary-reader dep wired in main.go; a suggest-tags endpoint and an apply-tags endpoint in internal/server/handlers_agent.go (routed in router.go) with the 409-stale + server-side-renormalize gate tests.
</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/11-per-page-llm-tag-suggestion/11-CONTEXT.md

# What 11-01 (Wave 1) produced and this plan consumes — do NOT re-implement:
@.planning/phases/11-per-page-llm-tag-suggestion/11-01-PLAN.md

# The single-shot mode + validate-and-retry analogs to mirror (Rewrite/Draft):
@internal/agent/agent.go
@internal/agent/propose.go
@internal/agent/prompts.go

# The key-free fake-model test harness to mirror for the SuggestTags test:
@internal/agent/dispatch_test.go

# The read-only 5-tool set-equality gate that MUST stay unchanged (do NOT add a 6th tool):
@internal/agent/tools.go
@internal/agent/tools_test.go

# The endpoint analogs to mirror — single-shot mode handlers + the /apply-patch editor+CSRF→pages.Save→409 gate:
@internal/server/handlers_agent.go
@internal/server/router.go

# The write path apply-tags reuses (Save takes frontmatter+body strings, 409 floor) and the okf.SetTags integration point:
@internal/pages/service.go
@internal/okf/repair.go
@internal/okf/emit.go
</context>

<artifacts_this_phase_produces>
This plan (Wave 2) produces:
- `agent.SuggestTags(ctx, pagePath) ([]string, baseRev string, err error)` — single-shot vocab-biased capped tag suggestion MODE + `validateTags` (cap/normalize/dedupe/reject) + `MaxSuggestedTags` constant (consumed by the suggest endpoint).
- A narrow `vocabularyReader` dep on `agent.Deps` (satisfied by 11-01's `*graph.Store.Vocabulary`), wired in main.go.
- `POST /agent/suggest-tags` (any-authed read) — returns suggestions + base revision + per-tag existing-vs-new flag.
- `POST /agent/apply-tags` (editor + CSRF) — re-validates/normalizes server-side, writes ONLY the tags lines via okf.SetTags through pages.Save → single-writer commit, 409 on stale.
Consumes from 11-01: `okf.SetTags` (TAG-03), `(*graph.Store).Vocabulary` (TAG-04).
Wave 3 (11-03) adds: the api client fns + react-query mutations + the "Suggest tags" trigger + the per-tag approval UI.
</artifacts_this_phase_produces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: SuggestTags single-shot mode + validateTags (vocab-biased, capped, normalized, key-free)</name>
  <files>internal/agent/suggesttags.go, internal/agent/suggesttags_test.go, internal/agent/agent.go, internal/agent/prompts.go</files>
  <read_first>
    - internal/agent/agent.go — Rewrite/Draft (lines ~396-423) are the single-shot analog: gen closure → generateOnce(ctx, msgs, maxTokens, temperature) → proposeWithRetry. Also Deps (lines ~197-203), the narrow reader-interface convention (pageReader/searcher/attachmentReader, lines ~148-184), generateOnce (lines ~329-346), singleShotTimeout, and the ErrAgentDisabled/cm==nil fail-closed guard. SuggestTags mirrors this shape but returns a []string of tags (not a body) AND the page base revision (like ProposePatch captures via Pages.Revision).
    - internal/agent/propose.go — proposeWithRetry (lines ~161-181) is the retry loop to MIRROR (not reuse — that one validates a body). Build a sibling tags-validate-and-retry loop: 1 initial + 2 retries, log attempt index + the (key-free) validation error, return a structured error after exhaustion, NEVER return an invalid set. ErrProposalInvalid (line ~43) is the existing sentinel; define a sibling ErrTagsInvalid for tags so the handler can map it cleanly.
    - internal/agent/prompts.go — buildRewriteMessages / buildDraftMessages (lines ~225-246), delimitUntrusted (lines ~148-150), retryHint (lines ~215-220), and the system-prompt-const style. Mirror these to build the suggest-tags messages: a fixed system prompt fixing the output contract, the page body delimited as untrusted DATA, the existing vocabulary supplied as a biasing hint, and a retry hint on attempts > 0.
    - internal/agent/dispatch_test.go — fakeChatModel (records gotMsgs/gotCtx, canned reply, calls counter), newFakeService, assertDeadlineAbout60s, and the "REJECTS … never returns it" + "called the model 3 times (validate-and-retry)" sub-test patterns. The SuggestTags test MIRRORS this exactly (key-free, no network).
    - internal/graph/query.go — 11-01 added `(*graph.Store).Vocabulary(ctx) ([]string, error)`; SuggestTags consumes it through a narrow dep (see action). Do NOT import internal/graph into internal/agent — define a one-method interface in agent and let *graph.Store satisfy it structurally (mirrors how pageReader is satisfied by *pages.Service).
    - cmd/okf-workspace/main.go — agent.NewService(cfg.Agent, &agent.Deps{...}) (lines ~310-315) and graphStore (lines ~246-249). Wire the new vocabulary dep here from graphStore.
  </read_first>
  <behavior>
    - validateTags(raw []string, vocab []string) ([]string, []bool, error):
      - Test: a clean list of 3 tags returns 3 normalized tags, no error.
      - Test: each tag is lowercased and trimmed (e.g. "  Release " → "release").
      - Test: duplicates AFTER normalization are deduped, first occurrence wins, order preserved (["Release","release","RELEASE"] → ["release"]).
      - Test: MORE than MaxSuggestedTags (5) input → result is capped to exactly 5 (first 5 surviving normalization+dedupe).
      - Test: garbage entries are rejected/dropped — empty/whitespace-only strings, entries over a sane per-tag length cap, and entries containing NUL or interior whitespace/control chars are removed (a tag is a single normalized token, never a sentence).
      - Test: an all-garbage / empty result returns ErrTagsInvalid (the retry loop treats this as a failed attempt).
      - Test: the existing-vs-new flag is correct — a normalized tag present in vocab flags existing=true; one absent flags existing=false. Vocab comparison is on the SAME normalized form.
    - SuggestTags(ctx, pagePath) ([]string, []bool, baseRev string, err error) — exact return shape is Claude's discretion, but it MUST surface (tags, existing-flags, baseRev):
      - Test (fake model): a canned JSON-array reply ["release","notes","draft"] → returns those 3 normalized tags + their existing flags + a non-empty base revision; the model was called once; the ctx carried the ~60s single-shot deadline; the system prompt sent is the suggest-tags prompt (not rewrite/draft/summarize).
      - Test (fake model): the page body reaches the user turn delimited as untrusted DATA, and the existing vocabulary appears in the prompt as a biasing hint (assert the vocab tokens are present in the sent messages).
      - Test (fake model): a garbage reply (e.g. prose, or >5 junk tokens, or a fenced blob) is rejected and RETRIED — the model is called 3 times (1 + 2 retries) and the final error wraps ErrTagsInvalid; no tags are returned.
      - Test: disabled service (cm==nil) fails closed with ErrAgentDisabled (mirror the dispatch_test disabled sub-test).
      - Test: a nil vocabulary dep is tolerated (vocab biasing is best-effort) — SuggestTags still produces validated tags, just without the bias hint; it does NOT panic or hard-error on a nil vocab reader.
  </behavior>
  <action>
    Create internal/agent/suggesttags.go implementing the SuggestTags single-shot MODE, mirroring Rewrite/Draft in agent.go and the validate-and-retry shape in propose.go. This is for TAG-01 (suggest) and consumes 11-01's Vocabulary (TAG-04) and is the write input for TAG-03 (apply).

    Constant: `MaxSuggestedTags = 5` (the locked CONTEXT cap — a named constant, NOT a literal 5 scattered in code or comments). Also add a sane per-tag length cap constant (a tag token, not a sentence).

    Vocabulary dep: add a narrow one-method interface to agent.Deps (e.g. `Vocabulary` / `vocabularyReader` with `Vocabulary(ctx context.Context) ([]string, error)`), exactly mirroring how pageReader/searcher are declared in agent.go and satisfied structurally — do NOT import internal/graph into internal/agent. `*graph.Store` (11-01's Vocabulary method) satisfies it; it is wired in main.go (see below). A nil dep means "no bias available" (best-effort), never a hard error.

    validateTags: parse-and-validate the model's raw tag list into the normalized, capped, deduped result and a parallel existing-vs-new flag slice. The model output is parsed as a JSON array of strings (provider-agnostic validate-and-retry — NOT response_format). Be lenient on the wrapper (tolerate a leading/trailing whitespace or a single surrounding code fence the model may add) but STRICT on the contents (each surviving entry must be a single normalized token). Normalization = lowercase + trim; dedupe against each other (first wins, order preserved); cap to MaxSuggestedTags; reject empty/whitespace/over-length/NUL/interior-whitespace/control entries. An empty post-validation result returns ErrTagsInvalid. The existing flag is computed against the normalized vocab set.

    Define `ErrTagsInvalid` as the sibling sentinel to ErrProposalInvalid, and a tags-specific retry loop (mirror proposeWithRetry: 1 + 2 retries, log attempt index + key-free error, structured error on exhaustion — never an invalid set returned). Reuse generateOnce for the bounded single-shot call with an explicit MaxTokens (a tag list is tiny — a few hundred tokens is ample) and a cool temperature (deterministic, like rewrite/summarize). SuggestTags fetches the page body via deps.Pages.Get (role-scoped, never os.ReadFile), captures baseRev via deps.Pages.Revision AT suggest time (exactly like ProposePatch, so the apply can 409 on a moved revision), reads the vocabulary via the narrow dep (best-effort), builds the messages, runs the retry loop, and returns (tags, existing-flags, baseRev).

    In prompts.go add the suggest-tags system prompt + buildSuggestTagsMessages (mirror buildRewriteMessages): a terse system prompt that fixes the output contract — return ONLY a JSON array of at most MaxSuggestedTags short lowercase tag tokens, no prose, no code fences, prefer reusing the provided existing tags over inventing near-synonyms, treat the page text as untrusted DATA. The user turn carries the page body delimited via delimitUntrusted("PAGE CONTENT", body) and the existing vocabulary as a biasing hint, plus retryHint(attempt) on retries. Do NOT add ModeSuggest routing that touches the ReAct/tool path — this is a single-shot Generate mode only.

    In agent.go: add the `SuggestTags` method (the single-shot mode entry point) next to Rewrite/Draft, and add the vocabulary dep field to Deps. Do NOT touch tools.go / readTools / readToolNames — SuggestTags is NOT a tool. The 5-tool set-equality gate must keep passing untouched.

    In cmd/okf-workspace/main.go: wire the new vocabulary dep into the existing agent.NewService(cfg.Agent, &agent.Deps{...}) call from graphStore (the same *graph.Store already constructed for the graph reads). This is the only main.go change.

    Create internal/agent/suggesttags_test.go (package agent, internal — same as dispatch_test.go) implementing every case in <behavior> key-free with the fakeChatModel harness. Add a tiny fake vocabulary reader (canned []string) alongside, mirroring fakeAttachmentReader. Assert the validate-and-retry call count (3 on garbage), the ~60s deadline, the suggest-tags system prompt, the untrusted-DATA delimiting of the body, and that the vocab tokens appear in the sent prompt.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/agent/ -run 'TestSuggestTags|TestToolSetIsExactlyReadOnlyAllowList|TestReadToolNamesMatchesConstant' -v && go vet ./internal/agent/</automated>
  </verify>
  <acceptance_criteria>
    - `agent.MaxSuggestedTags` exists and equals 5; no bare literal 5 stands in for the cap in the suggest logic.
    - `validateTags` lowercases+trims, dedupes (first wins, order preserved), caps to MaxSuggestedTags, rejects empty/whitespace/over-length/NUL/interior-whitespace/control entries, returns ErrTagsInvalid on an empty result, and produces a correct existing-vs-new flag against the normalized vocab.
    - `(*Service).SuggestTags` is a single-shot `ChatModel.Generate` mode (NOT a tool, NOT a ReAct turn): it captures the page base revision, biases the prompt with the vocabulary (best-effort; tolerates a nil vocab dep), and runs validate-and-retry (3 calls on garbage, wrapping ErrTagsInvalid).
    - The suggest-tags Generate call is wrapped in the single-shot ~60s timeout with an explicit MaxTokens (asserted via the fake's recorded ctx).
    - The model output is parsed as a JSON array of strings (validate-and-retry) — NOT response_format / JSON-schema (provider-agnostic).
    - The new vocabulary dep is a narrow interface in package agent; internal/graph is NOT imported by internal/agent; `*graph.Store` satisfies it; main.go wires it from graphStore.
    - tools.go/readTools/readToolNames are UNCHANGED — TestToolSetIsExactlyReadOnlyAllowList and TestReadToolNamesMatchesConstant still pass (no 6th tool).
    - TestSuggestTags passes key-free (no DEEPSEEK/OKF_LLM_API_KEY, no network); `go vet ./internal/agent/` is clean.
  </acceptance_criteria>
  <done>SuggestTags is a key-free, vocab-biased, capped, normalized single-shot mode with validate-and-retry; the read-only 5-tool gate is untouched; the vocabulary dep is wired from graphStore.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: suggest-tags + apply-tags endpoints (suggest read; apply editor+CSRF→okf.SetTags→pages.Save, 409 on stale)</name>
  <files>internal/server/handlers_agent.go, internal/server/router.go, internal/server/handlers_tags_test.go</files>
  <read_first>
    - internal/server/handlers_agent.go — handleRewrite/handleDraft (lines ~286-369) are the single-shot read-mode handler analog (decode, validate identifier, auditAgentMode, call the mode, writeAgentModeError). handleApplyPatch (lines ~516-593) is the EXACT apply analog: editor-gated + CSRF, decode, validate the page_path identifier + reject NUL, re-validate server-side BEFORE the write (defense-in-depth — never trust the client), reuse pages.Save(baseRevision), map pages.ErrPageNotFound→404 and pages.ErrStaleRevision→409 (write nothing), audit the approval, return 204. handleProposePatch (lines ~440-514) shows fetching the page + capturing churn for the audit; validIdentifier (lines ~63-67), auditAgentMode (lines ~374-382), writeAgentModeError (lines ~387-396), writeJSON/writeError usage.
    - internal/server/router.go — the agent route registration: any-authed single-shot modes (lines ~185-188: /agent/summarize-page, /agent/rewrite, /agent/draft) and the editor-gated subgroup (lines ~194-205) where /agent/propose-patch + /agent/apply-patch live (editor.Use RequireRole(editor); global nosurf covers the mutating POSTs). Register POST /agent/suggest-tags in the any-authed group (it is a read/proposal — like rewrite) and POST /agent/apply-tags in the editor subgroup (it is the consequential write — like apply-patch).
    - internal/pages/service.go — Save (lines ~213-256): it takes (ctx, path, body, frontmatter, baseRevision, user) where body+frontmatter are STRINGS; it enforces the 409 floor FIRST (current != baseRevision → ErrStaleRevision, writes nothing), then okf.Parse(assemble(frontmatter, body)) → okf.Repair → Emit → single-writer commit. Get (lines ~174-199) returns Page{Frontmatter: string(RawFront), Body, Revision}. The apply-tags handler must produce a NEW frontmatter region string with the approved tags set byte-stably (via okf.SetTags) and pass THAT frontmatter string (with the unchanged body) to Save, so the only changed bytes are the tags lines and the existing single-writer/409 machinery is reused unchanged.
    - internal/okf/repair.go + internal/okf/emit.go — 11-01's okf.SetTags(d, tags) sets the block-style tags sequence + FrontDirty; Emit re-marshals ONLY the frontmatter when FrontDirty (body byte-identical). To derive the new frontmatter-region string: okf.Parse(assemble(originalFrontmatter, body)) → okf.SetTags(doc, approved) → Emit → re-Parse the emitted bytes and take string(doc2.RawFront) as the new frontmatter region to hand to pages.Save. (Reusing pages.assemble's contract — the handler must not hand-roll a second frontmatter fence; pages.Save owns assembly.)
    - internal/agent/apply_test.go — the stalePagesStub + TestApplyStaleRevision pattern is the model for the apply-tags 409 gate test: a fake pages service whose revision moves between suggest-time Revision and apply-time Save, asserting Save with the stale baseRevision returns ErrStaleRevision and writes nothing.
    - internal/agent/suggesttags.go (Task 1) — the SuggestTags signature + validateTags the handlers call.
  </read_first>
  <behavior>
    - handleApplyTags (the 409 gate + server-side re-normalize): TestApplyTagsStaleRevision
      - Test: applying with a STALE base_revision (the page moved since suggest) returns HTTP 409 and writes NOTHING (no commit, page unchanged) — mirror apply_test.go's stalePagesStub via the handler/pages seam used by the other handler tests.
      - Test: applying with the CURRENT base_revision writes exactly once (control — the gate blocks stale, not all writes).
    - server-side re-validation/normalization on apply: TestApplyTagsRenormalizesServerSide
      - Test: a tampered client payload (un-normalized "  Release ", an over-cap list of >5, duplicates, a garbage/over-length token) is RE-normalized+capped+deduped+filtered server-side before the write — the written tags are the normalized/capped set, NOT the raw client list (never trust the client). Assert the bytes written contain only the cleaned tags.
    - byte-stable write: TestApplyTagsWritesOnlyTagsLines (if feasible at the handler/pages seam; else assert at the okf level)
      - Test: after apply, the page body bytes are unchanged and only the tags lines differ (the okf.SetTags invariant surfaced through the endpoint).
    - the read endpoint shape: TestSuggestTagsEndpoint
      - Test: handleSuggestTags returns 200 with suggestions + base_revision + per-tag existing flags for a valid page (using a fake agent/mode seam), and fails closed (structured error) when the agent is off/unreachable; a missing/invalid page_path → 400.
  </behavior>
  <action>
    Add two handlers to internal/server/handlers_agent.go and route them in router.go. This delivers TAG-01 over HTTP and reuses 11-01's okf.SetTags (TAG-03) on the write.

    handleSuggestTags (POST /agent/suggest-tags, any-authed read — mirror handleRewrite's shape):
    - Decode {page_path}; trim + validIdentifier(page_path) (reject empty/NUL/over-length/non-UTF-8), else 400 with friendly copy.
    - h.auditAgentMode(r, "suggest-tags", page_path) (non-secret mode + role only — never page content).
    - Call h.agent.SuggestTags(ctx, page_path); on error use writeAgentModeError (fail-closed: 503 disabled, 502 unreachable, 422 the model could not produce a clean set — extend the switch to map ErrTagsInvalid → the 422 "couldn't produce a clean result" copy, mirroring ErrProposalInvalid).
    - Return 200 JSON: an array of {tag, existing} objects + base_revision (define a small response struct mirroring proposePatchResponse). Render tags as plain strings (the frontend renders them as React text — no HTML).

    handleApplyTags (POST /agent/apply-tags, editor + CSRF — mirror handleApplyPatch's gate EXACTLY):
    - Decode {page_path, tags []string, base_revision}; trim + validIdentifier(page_path); reject if any tag contains NUL; if tags is empty after trimming → 400 (nothing to apply). Cap the request size sensibly (a tag list is tiny).
    - RE-VALIDATE + RE-NORMALIZE the client tags server-side via the agent's validateTags (defense-in-depth — NEVER trust the client's tags; the same cap/normalize/dedupe/filter the suggest path ran). A request that normalizes to an empty set → 422 (no clean tags to apply). This is the locked "server re-validates on apply" guarantee.
    - Fetch the page via h.pages.Get (404 on ErrPageNotFound). Produce the NEW frontmatter region string by writing the normalized tags byte-stably via okf.SetTags: okf.Parse(assemble(page.Frontmatter, page.Body)) → okf.SetTags(doc, normalized) → Emit → re-Parse → take the new frontmatter region string (do NOT hand-roll the fence; let pages.Save own final assembly). The body is passed through unchanged. (Encapsulate this in a small server helper, e.g. setTagsFrontmatter(frontmatter, body, tags) (string, error), so it is unit-testable and the byte-stable invariant is owned in one place.)
    - Write via h.pages.Save(ctx, page_path, page.Body, newFrontmatter, base_revision, actor) — the SAME single-writer commit + 409 floor /apply-patch uses. Map pages.ErrPageNotFound→404, pages.ErrStaleRevision→409 (the locked stale floor: write nothing, friendly "page changed — re-run" copy), other errors→500 generic.
    - Audit the approval via the existing approval action (reuse ActionAgentPatchApproval or an equivalent non-secret approval event; Detail carries only role — never the tags or page content). Return 204.

    router.go: register `authed.Post("/agent/suggest-tags", h.handleSuggestTags)` in the any-authed single-shot group (next to rewrite/draft), and `editor.Post("/agent/apply-tags", h.handleApplyTags)` in the editor-gated subgroup (next to apply-patch). Add a short comment noting suggest is read-only (any-authed) and apply is the consequential editor+CSRF write reusing pages.Save → single-writer → 409, and that NEITHER is an Eino tool (the 5-tool boundary is unchanged).

    Create internal/server/handlers_tags_test.go implementing the <behavior> cases. For the 409 + write-count + re-normalize assertions, mirror the test seam the existing handler tests use (the same fake pages.Service / handler wiring handlers_pages_test.go / the agent apply_test.go stalePagesStub use) so the test is deterministic and key-free (no git/model). At minimum: TestApplyTagsStaleRevision (409 + no write + control success), TestApplyTagsRenormalizesServerSide (tampered client list is cleaned before write), and the suggest endpoint happy/fail-closed/400 cases. If the page/Save seam used by these handlers makes the full HTTP round-trip heavy, unit-test setTagsFrontmatter directly for the byte-stable "only tags lines changed" invariant and drive the 409/renormalize assertions at the handler seam.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/server/ -run 'TestApplyTags|TestSuggestTags' -v && go test ./internal/server/ ./internal/agent/ ./internal/okf/ ./internal/graph/ && go vet ./internal/server/</automated>
  </verify>
  <acceptance_criteria>
    - POST /agent/suggest-tags is any-authed and returns 200 with per-tag {tag, existing} + base_revision for a valid page; fails closed (503/502/422) when the agent is off/unreachable/produces no clean set; 400 on a missing/invalid page_path.
    - POST /agent/apply-tags is editor-gated + CSRF (registered in the editor subgroup under the global nosurf), re-validates+normalizes the client tags server-side (a tampered/un-normalized/over-cap/garbage list is cleaned before the write — the client list is NEVER trusted), and a list that normalizes to empty → 422.
    - apply-tags writes ONLY the tags lines byte-stably via okf.SetTags and commits through pages.Save → the single-writer commit; the body and other frontmatter bytes are unchanged.
    - apply-tags reuses pages.Save's 409 floor: a moved base_revision → HTTP 409 and writes NOTHING (proven by TestApplyTagsStaleRevision); the current-revision control writes exactly once.
    - The read-only 5-tool allow-list is unchanged (no tool added in this task either).
    - `go test ./internal/server/ ./internal/agent/ ./internal/okf/ ./internal/graph/` passes; `go vet ./internal/server/` is clean.
  </acceptance_criteria>
  <done>suggest-tags returns vocab-biased capped suggestions + base revision + existing flags; apply-tags is the editor+CSRF gate that re-normalizes server-side, writes only the tags lines via okf.SetTags through pages.Save, and 409s on stale — proven by the stale + renormalize tests.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → /agent/suggest-tags | untrusted page_path (read scope hint) crosses here |
| browser → /agent/apply-tags | untrusted tags[] + base_revision + page_path cross here — the consequential write |
| LLM provider → SuggestTags | untrusted model output (the proposed tag list) crosses back here |
| page content → prompt | untrusted page body is spliced into the suggest prompt as DATA |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-01 | Tampering | /agent/apply-tags client tags[] | mitigate | Server RE-validates+normalizes via validateTags (lowercase/trim/dedupe/cap 5/reject garbage) before the write — the client list is never written verbatim (locked decision). |
| T-11-02 | Tampering | apply-tags concurrent edit | mitigate | Reuse pages.Save(base_revision) 409 floor — a moved revision writes NOTHING (TestApplyTagsStaleRevision). Never clobber a concurrent edit. |
| T-11-03 | Elevation of Privilege | apply-tags as a write | mitigate | Editor-gated (RequireRole editor) + global nosurf CSRF, read from the SESSION role — never client input. Suggest is any-authed read-only. |
| T-11-04 | Tampering | model output injected into tags | mitigate | validateTags rejects prose/garbage/over-cap/over-length/NUL/interior-whitespace; validate-and-retry (3 attempts) then a structured ErrTagsInvalid — a hallucinated set never reaches the page. |
| T-11-05 | Information Disclosure | audit Detail | mitigate | auditAgentMode records only the non-secret mode + session role — never the page content, the tags, the prompt, or the API key (mirrors the existing agent audit discipline). |
| T-11-06 | Elevation of Privilege | the read-only tool boundary | mitigate | SuggestTags is a MODE and apply is a NON-tool endpoint — the 5-tool set-equality build gate stays UNCHANGED (no 6th tool). |
| T-11-SC | Tampering | npm/pip/cargo installs | accept | No new dependency is added this phase (CONTEXT + UI-SPEC: "no new dependency"); no package-install task exists, so the supply-chain checkpoint is not triggered. |
</threat_model>

<verification>
- `go test ./internal/agent/ ./internal/server/ ./internal/okf/ ./internal/graph/` passes (the key-free SuggestTags test, the apply-tags 409 + server-side-renormalize gate, the unchanged 5-tool set-equality gate, and 11-01's okf.SetTags + Vocabulary gates — no regressions).
- `go build ./...` and `go vet ./internal/agent/ ./internal/server/` are clean (single CGO-free binary preserved).
- The read-only 5-tool allow-list is provably unchanged: TestToolSetIsExactlyReadOnlyAllowList + TestReadToolNamesMatchesConstant still pass with no edits to tools.go.
</verification>

<success_criteria>
- TAG-01 backend is complete: a vocab-biased, capped (MAX 5), normalized SuggestTags single-shot MODE (validate-and-retry, provider-agnostic, key-free testable) plus a read suggest endpoint and an editor+CSRF apply endpoint that re-validates/normalizes server-side, writes only the tags lines byte-stably via 11-01's okf.SetTags through pages.Save → the single-writer commit, and 409s on a moved revision instead of clobbering.
- TAG-03 apply-path reuse is exercised end-to-end (okf.SetTags drives the byte-stable write through the existing single-writer/409 machinery) and TAG-04's Vocabulary is consumed for prompt biasing.
- The read-only agent boundary is preserved (no 6th tool); no new dependency; the CGO-free single binary and files-as-truth + byte-stability invariants hold.
</success_criteria>

<output>
Create `.planning/phases/11-per-page-llm-tag-suggestion/11-02-SUMMARY.md` when done.
</output>
