---
phase: 11-per-page-llm-tag-suggestion
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - internal/okf/repair.go
  - internal/okf/settags_test.go
  - internal/okf/testdata/settags/no-tags-key.md
  - internal/okf/testdata/settags/existing-block-tags.md
  - internal/okf/testdata/settags/other-frontmatter.md
  - internal/graph/query.go
  - internal/graph/query_test.go
autonomous: true
requirements: [TAG-03, TAG-04]
must_haves:
  truths:
    - "Adding/replacing tags on a page changes ONLY the `tags` lines; body and all other frontmatter bytes are byte-identical"
    - "okf.SetTags creates a block-style `tags:` sequence when a page has no tags key, preserving every other byte"
    - "The workspace tag vocabulary (distinct existing tags) is queryable from the derived graph store for prompt biasing"
  artifacts:
    - path: "internal/okf/repair.go"
      provides: "okf.SetTags(d *Doc, tags []string) byte-stable sequence-node editor"
      contains: "func SetTags"
    - path: "internal/okf/settags_test.go"
      provides: "golden-file byte-stability round-trip gate (the #1 phase risk)"
      contains: "func TestSetTags"
    - path: "internal/graph/query.go"
      provides: "Store.Vocabulary read-only distinct-tag list for prompt biasing"
      contains: "func (s *Store) Vocabulary"
  key_links:
    - from: "internal/okf/repair.go"
      to: "internal/okf/emit.go"
      via: "SetTags sets FrontDirty so Emit re-marshals ONLY the tags lines"
      pattern: "d.FrontDirty = true"
    - from: "internal/graph/query.go"
      to: "page_tags table"
      via: "Vocabulary SELECTs distinct tags from the derived page_tags cache"
      pattern: "FROM page_tags"
---

<objective>
Build the two backend primitives the rest of Phase 11 multiplies: the byte-stable `okf.SetTags` frontmatter editor (TAG-03) and a read-only workspace tag-vocabulary accessor on the derived graph store (TAG-04). Both have zero upstream dependencies, so they form Wave 1.

Purpose: `okf.SetTags` is the load-bearing correctness gate for the whole phase — it must touch ONLY the `tags` lines of a page's YAML frontmatter (RESEARCH Pitfall 1, the #1 risk). Establishing it CORRECTLY here means the suggest→apply path in later waves multiplies a correct primitive, not a buggy one. `Store.Vocabulary` exposes the existing workspace tag set (already maintained in Phase 8's `page_tags`) so the Wave-2 suggest prompt can bias the model toward reusing tags over inventing near-synonyms.

Output: `okf.SetTags` in `internal/okf/repair.go` with its golden round-trip test + three testdata fixtures; `Store.Vocabulary` in `internal/graph/query.go` with a unit test.
</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

# The analog this plan mirrors (read BOTH — SetTags is the SequenceNode sibling of SetField):
@internal/okf/repair.go
@internal/okf/emit.go
@internal/okf/okf.go

# The vocabulary source already maintained by Phase 8 (private tagPageCounts shows the exact query):
@internal/graph/query.go
</context>

<artifacts_this_phase_produces>
This plan (Wave 1) produces:
- `okf.SetTags(d *okf.Doc, tags []string)` — byte-stable block-style `tags:` sequence editor (consumed by Wave 2's apply-tags handler).
- `okf.Store`-side: `(*graph.Store).Vocabulary(ctx) ([]string, error)` — distinct existing workspace tags (consumed by Wave 2's suggest handler for prompt biasing).
Later waves add: `SuggestTags` mode + `validateTags`, the suggest/apply endpoints + api client fns (Wave 2), the TagSuggest approval UI (Wave 3).
</artifacts_this_phase_produces>

<tasks>

<task type="auto">
  <name>Task 1: Add okf.SetTags byte-stable block-style sequence editor</name>
  <files>internal/okf/repair.go</files>
  <read_first>
    - internal/okf/repair.go — SetField (lines ~118-146) is the EXACT analog: it finds-or-appends the key, mutates in place, and sets d.FrontDirty. SetTags is the yaml.SequenceNode sibling. Reuse topMapping + the no-mapping materialization branch verbatim.
    - internal/okf/repair.go — appendField's FieldTags case (lines ~88-91) shows the existing empty-tags convention is FlowStyle ("[]"); SetTags with a NON-empty list must use block style per the locked CONTEXT decision.
    - internal/okf/emit.go — Emit re-marshals d.Front ONLY when FrontDirty; otherwise re-attaches RawFront verbatim. SetTags's only job is to mutate the yaml.Node correctly and set FrontDirty.
    - internal/okf/okf.go — Doc fields (Front yaml.Node, FrontDirty, HasFrontmatter); FieldTags constant.
  </read_first>
  <action>
    Add exported `SetTags(d *Doc, tags []string)` to internal/okf/repair.go, modeled on SetField but operating on a yaml.SequenceNode value.

    Behavior contract:
    - Resolve the top mapping via topMapping(d). When nil, materialize a fresh DocumentNode+MappingNode and promote the doc to having frontmatter — copy the exact branch SetField uses (set HasFrontmatter, openFence, closeFence via fenceLine(d.EOLStyle)).
    - Build the value node as a block-style sequence: a yaml.Node with Kind: yaml.SequenceNode, Tag: "!!seq", Style: 0 (block style — do NOT set yaml.FlowStyle), whose Content is one scalar node per tag (Kind: yaml.ScalarNode, Tag: "!!str", Value: tag). Per the locked CONTEXT decision, the canonical style for a NON-empty tags key is block-style (one `- tag` per line), distinct from appendField's empty-`[]` flow style.
    - Find an existing top-level `tags` key (compare mapping.Content[i].Value == FieldTags at even indices). If found, REPLACE the value node in place (assign the new sequence node to mapping.Content[i+1]) so the key keeps its position and surrounding keys/comments are untouched. If absent, append the key+sequence pair to the end of mapping.Content (mirroring appendField's append).
    - Set d.FrontDirty = true so Emit re-marshals. (SetTags is the surgical editor the okf.go FrontDirty comment anticipates.)
    - Do NOT normalize, dedupe, lowercase, or cap inside SetTags — it writes exactly the slice it is given, in order. Normalization is the agent/handler's job (Wave 2). SetTags is a pure structural editor.

    Add a doc comment above SetTags stating: surgically sets the `tags` key to a block-style sequence, marks FrontDirty so Emit rewrites ONLY the tags lines (body + other frontmatter byte-identical), and that the caller owns normalization/cap. Reference that it is the SequenceNode analog of SetField and the apply-tags write primitive (TAG-03).
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go build ./internal/okf/ && go vet ./internal/okf/</automated>
  </verify>
  <acceptance_criteria>
    - `okf.SetTags(d *okf.Doc, tags []string)` exists and compiles.
    - The value node it writes is Kind==yaml.SequenceNode with block style (Style != yaml.FlowStyle) and one !!str scalar per tag.
    - It sets d.FrontDirty = true.
    - An existing `tags` key is replaced in place (position preserved); an absent one is appended.
    - It performs no normalization (writes the slice verbatim, in order).
  </acceptance_criteria>
  <done>okf.SetTags is implemented as a block-style sequence editor that marks FrontDirty and leaves all other frontmatter keys untouched; package builds and vets clean.</done>
</task>

<task type="auto">
  <name>Task 2: Golden round-trip byte-stability test for okf.SetTags (the phase gate)</name>
  <files>internal/okf/settags_test.go, internal/okf/testdata/settags/no-tags-key.md, internal/okf/testdata/settags/existing-block-tags.md, internal/okf/testdata/settags/other-frontmatter.md</files>
  <read_first>
    - internal/okf/repair_test.go — the existing golden test style (read fixture from testdata, Parse, mutate, Emit, assert on the emitted string + a no-op round-trip). Mirror its structure and the `okf_test` external package convention.
    - internal/okf/roundtrip_test.go — the byte-identical-round-trip assertion pattern (Parse→Emit == src for unchanged docs).
    - internal/okf/testdata/ — existing fixture layout (subdirectory per feature).
  </read_first>
  <action>
    Create three testdata fixtures under internal/okf/testdata/settags/ (LF line endings, realistic OKF pages):
    - no-tags-key.md: a page with frontmatter that has type/title/description/timestamp but NO `tags` key, plus a multi-line Markdown body (include a heading, a paragraph, and a fenced code block that itself contains a `---` line, to prove the body is never re-serialized).
    - existing-block-tags.md: a page whose frontmatter already has a block-style `tags:` sequence with 2-3 tags, plus a body.
    - other-frontmatter.md: a page with `tags` plus several OTHER frontmatter keys in a specific order (e.g. type, title, a custom `author` key, description, tags, timestamp) and an indented/nested value, to prove non-tags keys and ordering are preserved.

    Create internal/okf/settags_test.go (package okf_test). Write a table-driven TestSetTags (or per-fixture sub-tests) proving, for EACH fixture:
    1. Parse the fixture → Doc.
    2. Capture the original bytes and compute the BODY region (everything after the closing fence) and the set of non-tags frontmatter lines.
    3. Call okf.SetTags(doc, []string{...}) with a fresh tag list.
    4. Emit() and assert the emit succeeds.
    5. BYTE-STABILITY ASSERTIONS (the gate):
       - The emitted BODY (bytes after the closing `---` fence) is byte-identical to the original body. Compare the raw body slices, not a normalized form.
       - Every frontmatter line that is NOT a `tags:` line or a `- <tag>` sequence item is byte-identical to the original (same content, same order). A practical way: re-Parse the emitted output, and assert that the ordered list of non-`tags` top-level keys AND their emitted lines equal the original's. Do NOT just grep for substrings — assert the diff is confined to the tags region.
       - The new tags appear as a block-style sequence (one `- tag` per line under `tags:`), in the order passed.
    6. Add an explicit no-tags-key sub-case asserting that after SetTags on no-tags-key.md, the ONLY added lines are the `tags:` key and its `- ` items (the body and all pre-existing frontmatter lines are unchanged).
    7. Add a control assertion: Parse→Emit with NO SetTags call returns bytes byte-identical to the fixture (the FrontDirty=false verbatim path), proving the test harness itself does not perturb bytes.

    Frame at least one assertion message around "only the tags lines changed" so a regression names the exact invariant.

    Discipline: this test file is the phase exit gate. Make the assertions structural (re-parse and compare regions), not a loose Contains check.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/okf/ -run TestSetTags -v</automated>
  </verify>
  <acceptance_criteria>
    - Three fixtures exist under testdata/settags/ covering: no tags key, existing block tags, other frontmatter preserved.
    - TestSetTags passes for all three fixtures.
    - For each fixture, the test asserts the emitted body bytes are byte-identical to the original body bytes.
    - For each fixture, the test asserts non-`tags` frontmatter lines/keys are preserved in content and order (a structural re-parse comparison, not a substring grep).
    - The control no-op round-trip (Parse→Emit with no edit) is byte-identical to the fixture.
    - `go test ./internal/okf/` passes overall (no existing test regressions).
  </acceptance_criteria>
  <done>The golden round-trip gate passes: okf.SetTags provably changes only the tags lines across all three fixtures, with body and other frontmatter byte-identical.</done>
</task>

<task type="auto">
  <name>Task 3: Add Store.Vocabulary read-only distinct-tag accessor</name>
  <files>internal/graph/query.go, internal/graph/query_test.go</files>
  <read_first>
    - internal/graph/query.go — tagPageCounts (lines ~378-396) is the exact query shape (SELECT ... FROM page_tags) and the rows-scan + rows.Err() idiom to mirror. allPages (lines ~206-) shows the read-only QueryContext pattern.
    - internal/graph/query_test.go — the test harness for the query layer (how a Store is stood up with a seeded page_tags table in tests).
    - internal/graph/harness_test.go — the test fixture/seed helpers.
  </read_first>
  <action>
    Add exported `Vocabulary(ctx context.Context) ([]string, error)` to internal/graph/query.go. It returns the workspace's existing tag vocabulary — the distinct set of tags currently present across all pages in the derived page_tags cache — for biasing the Wave-2 suggestion prompt (TAG-04).

    Implementation: SELECT DISTINCT tag FROM page_tags ORDER BY tag (deterministic order), scan into a []string, return it with rows.Err(). Mirror tagPageCounts's QueryContext + defer rows.Close() + scan-loop + rows.Err() idiom exactly. Return a non-nil empty slice (not nil) when there are no tags, so callers never special-case nil. Add a doc comment noting it reads the DERIVED cache (never the source of truth) and is used only to bias the tag-suggestion prompt, and that ordering is deterministic for stable prompts/tests.

    Add a unit test in internal/graph/query_test.go (mirror the existing query_test harness): seed page_tags with a few pages sharing/overlapping tags, call Vocabulary, and assert it returns the DISTINCT tag set sorted ascending (deduped across pages). Add an empty-store sub-case asserting a non-nil empty slice.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/graph/ -run Vocabulary -v</automated>
  </verify>
  <acceptance_criteria>
    - `(*graph.Store).Vocabulary(ctx) ([]string, error)` exists and compiles.
    - It returns DISTINCT tags from page_tags, sorted ascending, deduped across pages.
    - It returns a non-nil empty slice when no tags exist.
    - The unit test passes; `go test ./internal/graph/` passes overall.
  </acceptance_criteria>
  <done>Store.Vocabulary returns the deterministic, deduped existing tag vocabulary from the derived page_tags cache; tested including the empty case.</done>
</task>

</tasks>

<verification>
- `go test ./internal/okf/ ./internal/graph/` passes (the golden SetTags gate + Vocabulary unit test + no regressions).
- `go build ./...` and `go vet ./internal/okf/ ./internal/graph/` are clean.
- Manual spot-check: the SetTags golden test asserts BODY bytes are byte-identical and non-tags frontmatter is preserved by region, not by substring.
</verification>

<success_criteria>
- okf.SetTags writes a block-style tags sequence, marks FrontDirty, and (proven by the golden test) changes ONLY the tags lines across no-tags / existing-tags / other-frontmatter fixtures (TAG-03 foundation).
- Store.Vocabulary exposes the deduped, deterministic existing tag vocabulary for downstream prompt biasing (TAG-04 foundation).
- No new dependency; single CGO-free binary preserved; files-as-truth and byte-stability invariants upheld.
</success_criteria>

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