---
phase: 29-tag-regression-fix
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [internal/okf/hashtag.go, internal/okf/hashtag_test.go, internal/okf/validate.go, internal/okf/validate_test.go, internal/pages/tagscan.go, internal/pages/tagscan_test.go, internal/pages/batch.go, internal/pages/batch_test.go, internal/server/handlers_tags_admin.go, internal/server/handlers_tags_admin_test.go, web/src/components/admin/TagManagement.tsx, web/src/api/client.ts, web/src/lib/cm/hashtag.parity.test.ts]
autonomous: false
requirements: [TR-01, TR-02, TR-03, TR-04, TR-05, TR-06]

must_haves:
  truths:
    - "RISK-FIRST ORDER (CONTEXT D-01), non-negotiable: (0) ValidateInlineTag validator → (1) okf.RewriteHashtags + the offset-level whole-document Lezer-parity test TOGETHER → (2) body-inclusive enumeration in the PAGES SERVICE → (3) wire into rename/merge/delete (widened predicate + body channel) → (4) preview body-scope UI → (5) TR-06 detect+report. The central drift risk (a second #tag parser) is retired by (1) BEFORE any handler depends on the primitive."
    - "TR-01: okf.RewriteHashtags(body []byte, old, new string) ([]byte, bool) rewrites body #tag occurrences (rename #old→#new; merge #source→#target; delete new==\"\"). It skips EVERY region the Lezer parser excludes — fenced (``` / ~~~), inline-code backtick runs, \\#-escape, indented (4-space/tab) code blocks, HTML blocks, AND the ATX-heading leading #. It reuses the exact hashtag.ts boundary predicates (isWs start boundary, isBody alphabet [A-Za-z0-9_/-], ≥1 non-digit, trailing-/ trim). CONSERVATIVE / skip-when-uncertain: an unclassifiable # run is left UNCHANGED. Byte-identical on no-match."
    - "TR-01 delete whitespace rule (D-03a): delete removes #+token and collapses exactly ONE adjacent space ONLY when the token was space-bounded on both sides; never touches whitespace otherwise; never merges across a newline. TR-01 merge adjacent-dup rule (D-03b): a merge yielding `#b #b` leaves both tokens as-is."
    - "TR-02: an OFFSET-LEVEL, WHOLE-DOCUMENT parity test whose expected results are generated by the REAL Lezer parser (not a hand-transcribed name list) over block-context fixtures (indented code, HTML blocks, tables, lazy blockquote continuation, >#tag block-marker abut) PLUS the full hashtag.test.ts positive/negative corpus. It asserts byte offsets / spliced output, NOT merely tag-set membership. The corpus is single-sourced (a shared checked-in fixture consumed by both sides); a Lezer-corpus addition the Go scanner misses FAILS CI."
    - "TR-03 validator (BLOCKER): okf.ValidateInlineTag gates rename new_name AND merge target at the handler (400 on fail) BEFORE any write — inline-#tag grammar subset (no space, ≥1 non-digit, [A-Za-z0-9_/-], slash policy). ValidateTagCharset is NOT loosened and NOT the sole gate. Delete needs no new-token validation. No space/metachar/all-digit token can reach a body."
    - "TR-03 wire-in (BLOCKER): (a) buildTagApplyItems inclusion predicate widens to (frontmatterChanged OR bodyWillChange), bodyWillChange computed by running RewriteHashtags on the batch's FRESHLY-READ body; a body-only page (frontmatter unchanged) STILL produces a write. (b) pages.TagApplyItem + ApplyTagsBatch gain a body-rewrite channel keyed to the op so ApplyTagsBatch no longer emits doc.Body verbatim for affected pages: per affected page ONE write updates body (RewriteHashtags) AND frontmatter (SetTagsFrontmatter), committed via the single-writer path (N pages → 1 commit). Byte-stable: only tags-region frontmatter lines + matched body #tag bytes change."
    - "TR-03 regression closed (GOLDEN tests): rename #ops→#devops on a page with body #ops → server rewrites body to #devops + frontmatter [devops]; then reconcileFrontmatterTags on the next (unchanged-body) save is byte-stable and `ops` does NOT reappear. Equivalent goldens for merge, delete, AND a body-only page. PLUS a stale-open-client 409 integration test (a save carrying the pre-rewrite body at the pre-rewrite base_revision is rejected ErrStaleRevision, never clobbering the rewrite). Per-page 409 doesn't sink the batch; audit-log entries recorded (body+frontmatter scope)."
    - "TR-04: body-inclusive affected-set enumeration lives in the PAGES SERVICE (reads bodies through the SEC-01 resolver, mirroring the tree.go/templates.go Tree()+Resolve pattern), NOT in internal/graph/query.go (whose 'reads NO bodies — only path enumeration' invariant MUST hold). It finds a page whose #tag is body-only (absent from frontmatter → invisible to PagesByTag/page_tags). Same skip rules as the graph rebuild (live .md only; no trashed/non-.md; no path escape). One-shot per-operation scan. A merge whose source is in page A's body and target in page B's frontmatter resolves both."
    - "TR-05: the rename/merge/delete preview response distinguishes frontmatter-only changes from pages whose body #tags will also be rewritten (a per-page body-affected marker); TagManagement.tsx surfaces the body-rewrite scope in the preview so the admin isn't surprised prose changes. client.ts TagPreviewPage type extended accordingly."
    - "TR-06 (D-07): scoped to DETECT + REPORT (not auto-fix, unless a bounded provably-safe remediation is found and stated). A one-shot workspace scan flags pages whose body #tags disagree with frontmatter in a way indicative of a pre-fix rename/delete, and reports them. The ROADMAP over-claim ('for all pages including never-reopened') is softened in the docs to 'for all FUTURE operations' + the surfaced historical set. The chosen path is stated in the SUMMARY."
    - "Full suites green at HEAD: backend `go build ./... && go vet ./... && go test ./...`; from web/ `npx tsc -b`, `npx vitest run` (incl. the new parity test), `npm run build` (assertLazyChunks active). All existing v1.3 tag-admin tests (handlers_tags_admin_test.go RBAC/preview/apply/validation) still pass. Feature-scoped commits per task group."
---

<objective>
Make workspace tag rename/merge/delete durable against v1.4's inline-#tag materialization by
rewriting affected pages' body #tags SERVER-SIDE, in the same byte-stable batched commit as the
frontmatter change — via a new code-region-aware okf.RewriteHashtags primitive proven in lock-step
with the TS Lezer grammar by an offset-level whole-document parity test. Close the confirmed
regression at the source for all FUTURE operations (including never-reopened pages); surface — not
silently claim — historical corruption (TR-06 detect+report). TR-01…TR-06. Builds on the Phase-28
committed baseline. Fully sequential, risk-first.
</objective>

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

<context>
@.planning/phases/29-tag-regression-fix/29-CONTEXT.md
@.planning/REQUIREMENTS.md §TR-01…TR-06
@.planning/ROADMAP.md §"Phase 29: Tag-Regression Fix"
</context>

<grounding_facts>
Verified by reading the code THIS session (SMTC OFFLINE — grep/read confidence, Structural tier; Phase-29 surface files are UNCHANGED by Phase 28 — 0 commits touched any of them):

**The scanner pattern to model (internal/okf/links.go):**
- `FindLinks(body []byte) []Link` (:21-83) — a STRUCTURAL byte scanner that already skips fenced
  (``` / ~~~), inline-code, and \-escape. Helpers: `fenceAt` (:85-109), `skipFencedBlock` (:111-142),
  `isClosingFence` (:144-165), `skipInlineCode` (:167-194). `RewriteLinks` (:337-386) splices ONLY the
  matched bytes, byte-stable on no-match. RewriteHashtags mirrors this and ADDS indented-code +
  HTML-block + ATX-heading skips.
- okf package files: emit.go, headings.go, links.go, okf.go, repair.go. Add `hashtag.go` +
  `validate.go` (+ tests). `okf.Parse` exists (used by readPageTags).

**The grammar to pin against (web/src/lib/cm/hashtag.ts):**
- Boundary predicates at :44-68: `isWs` (32/9/10/13/12), `isBody` [A-Za-z0-9_/-] (:49-55),
  `isNonDigit` [A-Za-z_-] (:59-60), `scanBody` (:70-88, trailing-/ trim + ≥1-non-digit).
- Start boundary rule (file header :22-28): a `#` begins a tag only when preceding char is
  start-of-input OR whitespace (excludes foo#bar, /#frag, \#esc).
- `tagParserExtensions` (:115-127) + `tagParser` (:133) + `inlineTagsFromBody` (:135-150) — the
  Lezer parse to drive the parity fixture's expected output.
- Corpus: web/src/lib/cm/hashtag.test.ts (positive/negative + Phase-21 adversarial + code-region).

**Existing validator (internal/okf/repair.go):**
- `ValidateTagCharset` (:159) = `^[a-zA-Z0-9_ -]+$` — permits space + all-digit, FORBIDS slash.
  INSUFFICIENT for body injection; keep its frontmatter/SEC-02 role, add the stricter ValidateInlineTag.

**The write surface (internal/server/handlers_tags_admin.go):**
- `buildTagApplyItems` (:194-230) — appends a TagApplyItem ONLY when the frontmatter set `changed`
  (:221). THIS is the body-only-page drop. Widen the predicate here.
- Handlers: `handleTagRename` (:243-315) validates `ValidateTagCharset(newName)` (:256);
  `handleTagMerge` (:317-379) — NO validator (add ValidateInlineTag(target)); `handleTagDelete`
  (:381-440) — no new-token validator needed.
- Compute funcs: `computeRenameTags` (:123), `computeMergeTags` (:142), `computeDeleteTags` (:166)
  drive the FRONTMATTER change; the body-rewrite decision is a NEW parallel computation in the batch.
- `normalizeTag` (:73) = ToLower+TrimSpace; `readPageTags` (:79); preview types `tagPreviewResponse`
  (:54) + `tagPreviewPage` (:60, fields PagePath/CurrentTags/NewTags — extend with a body-affected marker).

**The batch (internal/pages/batch.go):**
- `TagApplyItem` (:47, fields PagePath/Tags/BaseRevision — add a body-rewrite spec). `ApplyTagsBatch`
  (:77-187): reads freshly (:120 `s.repo.Read`), parses (:124 `okf.Parse`), builds frontmatter via
  `SetTagsFrontmatter` (:133) then `emitForWrite(newFront, string(doc.Body))` (:137) — **body verbatim,
  the regression**. Replace the body arg with RewriteHashtags output. Per-page 409 floor at :115-118
  (`current != it.BaseRevision` → TagApplyStale). ONE commit via `EnqueueCommit` (:161-174).
  `SetTagsFrontmatter` (:214-240) is the shared byte-stable frontmatter write.

**Enumeration surfaces (pages service):**
- `Service.Get` (service.go:174), `Service.Tree` (tree.go:36), `ListTemplates` (templates.go:42) — the
  `repo.Tree()` + `repo.Resolve` SEC-01 pattern to mirror for the body scan (D-05). `ErrStaleRevision`
  (service.go:30,236) is the 409 the stale-open-client test pins. `graph/query.go` "reads NO bodies"
  invariant is at query.go's GraphData/enumeration (Phase-28 touched query.go for local-graph removal —
  RE-CONFIRM the exact invariant line before referencing; do NOT add a body read there regardless).

**Client (web/src/api/client.ts):**
- `TagPreviewPage` (:1351-1355: page_path/current_tags/new_tags) + `TagPreview` (:1357-1361) +
  `renameTag`/`mergeTag`/`deleteTag` (:1384-1413). Extend TagPreviewPage with a body-affected flag.

**The regression counterparty (web/src/lib/tag-reconcile.ts):**
- `reconcileFrontmatterTags` (:51) computes reconciled = normalize((fmTags − removedInline) ∪ curInline);
  removedInline = prevInline − curInline. The golden test drives server-rewrite THEN this on the next
  (unchanged-body) save → the renamed-away tag must NOT reappear.
</grounding_facts>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: TR-03 blocker — okf.ValidateInlineTag (the inline-#tag grammar-subset validator)</name>
  <files>internal/okf/validate.go, internal/okf/validate_test.go</files>
  <read_first>
    - internal/okf/repair.go:155-185 — ValidateTagCharset (the WEAKER existing gate; pattern to follow, NOT reuse)
    - web/src/lib/cm/hashtag.ts:44-88 — the exact grammar the validator must be a subset of (isBody, isNonDigit, scanBody, trailing-/ trim)
    - web/src/lib/cm/hashtag.test.ts — the positive/negative corpus the validator must agree with
  </read_first>
  <action>
    Add `func ValidateInlineTag(tag string) error` to a new `internal/okf/validate.go`. It enforces the
    inline-#tag grammar SUBSET the Lezer body grammar can represent, so a rename/merge target can be safely
    spliced into a body: reject empty; reject any space/whitespace/control char; require the whole token to
    match the body alphabet [A-Za-z0-9_/-]; require ≥1 non-digit [A-Za-z_-] (so `1984`, `12/34` are
    rejected); slash policy — `/` separates segments (nested `a/b` OK) but no leading/trailing/doubled slash
    and a token that is only digits-and-slashes is rejected. Return a clear error naming the violated rule.
    Do NOT modify or loosen ValidateTagCharset (it keeps its frontmatter/SEC-02 role). RED first: write
    validate_test.go table-driven over the SAME positive/negative corpus hashtag.test.ts uses (accept:
    deploy, a/b, a_b-c, abc, fff, world123; reject: "" , "a b", "1984", "12/34", "/frag", "a/", "a//b",
    "foo#bar" is N/A here since the token has no #, but "#deploy" with a leading # must be rejected — the
    validator takes the BARE tag name without #). GREEN.
  </action>
  <acceptance_criteria>
    - internal/okf/validate.go defines ValidateInlineTag; ValidateTagCharset is unchanged (grep repair.go)
    - `go test ./internal/okf/ -run ValidateInlineTag` passes with ≥12 cases (accept + reject the corpus above)
    - rejects: empty, space, all-digit, digits+slashes, leading/trailing/double slash, a leading '#'
    - accepts: deploy, a/b, a_b-c, abc, fff, world123
  </acceptance_criteria>
</task>

<task type="auto" tdd="true">
  <name>Task 2: TR-01 + TR-02 — okf.RewriteHashtags primitive + the offset-level Lezer-parity test (TOGETHER)</name>
  <files>internal/okf/hashtag.go, internal/okf/hashtag_test.go, web/src/lib/cm/hashtag.parity.test.ts, internal/okf/testdata/hashtag_corpus.json</files>
  <read_first>
    - internal/okf/links.go:21-194 — FindLinks + fenceAt/skipFencedBlock/isClosingFence/skipInlineCode (reuse/mirror these skip helpers), and RewriteLinks:337-386 (byte-stable splice pattern)
    - web/src/lib/cm/hashtag.ts:44-150 — the grammar predicates + tagParser + inlineTagsFromBody (drives the expected offsets)
    - web/src/lib/cm/hashtag.test.ts — the corpus to fold into the shared fixture
  </read_first>
  <action>
    (A) Implement `func RewriteHashtags(body []byte, old, new string) ([]byte, bool)` in internal/okf/hashtag.go.
    `new==""` = delete. Scan body byte-by-byte, tracking block structure to SKIP every Lezer-excluded region:
    fenced code (reuse fenceAt/skipFencedBlock), inline code (reuse skipInlineCode), \#-escape, indented
    (4-space/tab line-start) code blocks, HTML blocks, and ATX-heading leading # (a line whose first
    non-space run is 1-6 '#' followed by space/EOL — its leading # is a HeaderMark, not inline). For each
    candidate `#`, apply the START BOUNDARY rule (preceding char is start-of-line/input or whitespace) then
    scanBody (isBody alphabet, ≥1 non-digit, trailing-/ trim) EXACTLY as hashtag.ts does. On a case-insensitive
    match of the tag name: rename/merge splice `#`+new; delete removes `#`+token with the D-03a whitespace rule
    (collapse one space only when space-bounded both sides, never across newline). Merge adjacent-dup rule
    (D-03b): leave `#b #b`. CONSERVATIVE: any # run whose region can't be confidently classified is left
    UNCHANGED. Byte-identical when nothing matches (return changed=false).
    (B) Build the single-source parity fixture: a small Node/vitest generator (or an inline step in
    hashtag.parity.test.ts) runs the REAL tagParser/inlineTagsFromBody-equivalent over whole-document
    fixtures and emits, per fixture, the expected #tag spans (byte offsets) — written to
    internal/okf/testdata/hashtag_corpus.json (checked in). Fixtures MUST include: the full hashtag.test.ts
    positive/negative corpus, indented-code (`\t#deploy`→none), HTML block, a GFM table, lazy blockquote
    continuation, and `>#tag` block-marker abut.
    (C) hashtag_test.go loads the JSON corpus and asserts, for each fixture, that RewriteHashtags' recognized
    tag SPANS (offsets) match the Lezer-generated expected spans — OFFSET-level, not just name-set — and that
    a rename splices the correct bytes. web/src/lib/cm/hashtag.parity.test.ts asserts the JSON was generated
    from the CURRENT grammar (regenerate-and-diff, so a Lezer-corpus change that the Go side would miss fails
    CI). RED→GREEN on both sides.
  </action>
  <acceptance_criteria>
    - internal/okf/hashtag.go defines RewriteHashtags; internal/okf/testdata/hashtag_corpus.json is checked in
    - `go test ./internal/okf/ -run RewriteHashtags` passes: offset-parity over the whole corpus, incl. every
      code-region exclusion (fenced/inline/escape/indented/HTML/ATX) and the delete/merge whitespace rules
    - the parity test asserts BYTE OFFSETS / spliced output, not merely tag-set membership (grep the test for an offset/span assertion)
    - from web/: `npx vitest run src/lib/cm/hashtag.parity.test.ts` passes and would FAIL if the Go corpus drifts from the Lezer grammar (regenerate-and-diff)
    - RewriteHashtags is byte-identical (changed=false) on a no-match body
  </acceptance_criteria>
</task>

<task type="auto" tdd="true">
  <name>Task 3: TR-04 — body-inclusive affected-set enumeration in the pages service</name>
  <files>internal/pages/tagscan.go, internal/pages/tagscan_test.go</files>
  <read_first>
    - internal/pages/tree.go:36 + internal/pages/templates.go:38-46 — the repo.Tree()+repo.Resolve SEC-01 enumeration pattern to mirror
    - internal/pages/service.go:174 (Get) — the SEC-01-resolver body read
    - internal/graph/query.go — the "reads NO bodies — only path enumeration" invariant (re-confirm its current line after Phase-28's local-graph edits); this scan must NOT go here
    - internal/okf/hashtag.go (Task 2) — reuse the SAME region-aware scan to detect a body #tag occurrence
  </read_first>
  <action>
    Add a pages-service method, e.g. `func (s *Service) PagesWithBodyTag(ctx, tag string) ([]string, error)`
    (or a combined enumerator the handlers call), that walks live .md pages via repo.Tree()+repo.Resolve
    (SAME skip rules as the graph rebuild: skip dirs, non-.md, trash prefix; no path escape), reads each body
    through the SEC-01 resolver, and returns the paths whose body contains `#tag` per the region-aware scan
    (reuse okf.RewriteHashtags' recognition, or a shared okf.FindHashtags helper if cleaner — extract one if it
    keeps Task 2's scanner single-sourced). One-shot per call (never per-keystroke). It must find a body-only
    page (tag in body, not frontmatter). Table-driven test: seed pages (frontmatter-only, body-only, both,
    tag-in-fenced-code→excluded, trashed→skipped) and assert the returned set is exactly the body-bearing live
    pages. Do NOT add any body read to internal/graph/query.go.
  </action>
  <acceptance_criteria>
    - internal/pages/tagscan.go defines the body-inclusive enumerator; internal/graph/query.go has NO new body read (grep confirms invariant intact)
    - `go test ./internal/pages/ -run BodyTag` passes: finds body-only pages, excludes fenced-code occurrences, skips trashed/non-.md
    - the scan reuses the Task-2 region-aware recognition (single-sourced, not a third #tag parser)
  </acceptance_criteria>
</task>

<task type="auto" tdd="true">
  <name>Task 4: TR-03 — wire the primitive + enumeration into rename/merge/delete (validator + widened predicate + body channel)</name>
  <files>internal/pages/batch.go, internal/pages/batch_test.go, internal/server/handlers_tags_admin.go, internal/server/handlers_tags_admin_test.go</files>
  <read_first>
    - internal/server/handlers_tags_admin.go:194-230 (buildTagApplyItems — widen the predicate), :243-315 rename, :317-379 merge (add validator), :381-440 delete
    - internal/pages/batch.go:47 (TagApplyItem), :77-187 (ApplyTagsBatch — replace the verbatim doc.Body emit at :133-137), :214-240 (SetTagsFrontmatter)
    - internal/pages/service.go:30,208-237 (ErrStaleRevision 409 floor — the stale-open-client invariant)
    - web/src/lib/tag-reconcile.ts:51-85 (the reconcile the golden test drives)
  </read_first>
  <action>
    (A) Handlers: add `okf.ValidateInlineTag(newName)` to handleTagRename (alongside/replacing the
    ValidateTagCharset gate as the stricter check) and `okf.ValidateInlineTag(target)` to handleTagMerge —
    400 before any write. Widen affected-set enumeration to union PagesByTag(oldName/source) with the Task-3
    body-inclusive enumerator (so body-only pages are included).
    (B) TagApplyItem (batch.go:47) gains a body-rewrite spec (e.g. Op + Old + New, or a prepared closure) so
    the batch can rewrite the body. buildTagApplyItems (widened predicate): include a page when
    (frontmatterChanged OR bodyWillChange). Compute bodyWillChange from the page's body; but the AUTHORITATIVE
    rewrite decision uses the batch's FRESHLY-READ body to avoid TOCTOU — set the item's body-rewrite spec and
    let ApplyTagsBatch compute the actual new body from its own read.
    (C) ApplyTagsBatch: after `okf.Parse` (:124), if the item has a body-rewrite spec, compute
    `newBody, bodyChanged := okf.RewriteHashtags(doc.Body, old, new)`; pass `newBody` (not verbatim doc.Body)
    to emitForWrite alongside SetTagsFrontmatter. If NEITHER frontmatter NOR body changed for a page, skip it
    (no-op). Preserve: per-page 409 (base_revision) exclusion without sinking the batch; ONE commit for the
    batch; audit-log entries (now body+frontmatter scope).
    (D) Golden + integration tests (batch_test.go + handlers_tags_admin_test.go): rename #ops→#devops on a
    page with body `#ops` → assert committed body has `#devops`, frontmatter `[devops]`, and that feeding the
    committed body through a Go-side equivalent of reconcile (or a cross-checked expectation) leaves `ops`
    gone. Equivalent goldens for merge, delete, and a BODY-ONLY page (frontmatter unchanged, body rewritten,
    page still written). Stale-open-client 409 test: a Save carrying the pre-rewrite body at the pre-rewrite
    base_revision → ErrStaleRevision (409), rewrite not clobbered. Confirm all EXISTING handlers_tags_admin
    tests (RBAC/preview/apply/validation) still pass.
  </action>
  <acceptance_criteria>
    - merge + rename reject an invalid inline tag (space/metachar/all-digit) with 400 before any write (new test cases)
    - a body-only page (tag only in body) is enumerated AND written (frontmatter unchanged) — golden test proves it
    - `go test ./internal/pages/ ./internal/server/` passes incl. the rename/merge/delete body-rewrite goldens + the stale-open-client 409 test
    - byte-stability: a golden asserts only tags-region frontmatter lines + matched body #tag bytes changed
    - ApplyTagsBatch no longer emits doc.Body verbatim for a body-affected page (grep/read confirms the RewriteHashtags call)
    - regression closed: the rename golden shows `ops` does NOT reappear after the reconcile step
  </acceptance_criteria>
</task>

<task type="auto" tdd="false">
  <name>Task 5: TR-05 — preview surfaces body-rewrite scope (server response + admin UI)</name>
  <files>internal/server/handlers_tags_admin.go, web/src/api/client.ts, web/src/components/admin/TagManagement.tsx</files>
  <read_first>
    - internal/server/handlers_tags_admin.go:54-63 (tagPreviewResponse/tagPreviewPage — add a body-affected marker)
    - web/src/api/client.ts:1351-1361 (TagPreviewPage/TagPreview types)
    - web/src/components/admin/TagManagement.tsx:100-290 (rename/merge/delete preview rendering)
  </read_first>
  <action>
    Add a per-page body-affected marker to tagPreviewPage (e.g. `BodyAffected bool json:"body_affected"`),
    set it in buildTagApplyItems from the body-inclusive computation, and thread it through the preview
    responses. Extend client.ts TagPreviewPage with `body_affected?: boolean`. In TagManagement.tsx's preview
    lists (rename/merge/delete dialogs), mark pages whose body will be rewritten (e.g. a small "prose" badge or
    a note "N page(s) also have body #tags that will be rewritten") so the admin approves the true diff. Prose
    only; keep the preview→approve contract. Re-run vitest for TagManagement if a test exists; add a minimal
    assertion that a body-affected preview entry renders its marker.
  </action>
  <acceptance_criteria>
    - the preview response distinguishes frontmatter-only from body-affected pages (server field + client type)
    - TagManagement.tsx surfaces the body-rewrite scope in the preview (grep for the new marker/copy)
    - from web/: `npx tsc -b` clean; `npx vitest run` green (incl. any TagManagement test)
  </acceptance_criteria>
</task>

<task type="auto" tdd="false">
  <name>Task 6: TR-06 — detect+report stale-body corruption from pre-fix operations</name>
  <files>internal/pages/tagscan.go, internal/pages/tagscan_test.go</files>
  <read_first>
    - internal/pages/tagscan.go (Task 3 — reuse the body scan)
    - .planning/REQUIREMENTS.md §TR-06 — the detect+report scope + the ROADMAP over-claim to soften
  </read_first>
  <action>
    Implement the D-07 DETECT+REPORT path: a bounded one-shot workspace scan that flags pages whose body
    #tags disagree with frontmatter in a way indicative of a pre-fix rename/delete (e.g. a body #tag whose
    frontmatter counterpart is absent). Expose it as a service method returning the flagged set (path + the
    disagreeing tags), consumable by an admin/CLI surface (the executor picks the least-invasive surface — a
    read-only report method + a test is sufficient; a full admin panel is optional). Do NOT auto-fix. Add a
    test seeding a pre-fix-corrupted page (body `#ops`, frontmatter without `ops`) and assert it is reported.
    Record in the SUMMARY that TR-06 is scoped to detect+report and that the ROADMAP "for all pages including
    never-reopened" claim is softened to "for all FUTURE operations + surfaced historical set."
  </action>
  <acceptance_criteria>
    - a service method reports pages with pre-fix stale-body/frontmatter disagreement; `go test ./internal/pages/ -run Stale` (or similar) passes
    - it DETECTS+REPORTS only (no auto-write); the SUMMARY states the chosen scope + the softened claim
  </acceptance_criteria>
</task>

<task type="auto" tdd="false">
  <name>Task 7: Full-suite gate + feature-scoped commits</name>
  <files></files>
  <read_first>
    - .planning/phases/29-tag-regression-fix/29-CONTEXT.md — the risk-first order + byte-stability invariants
  </read_first>
  <action>
    Run the FULL gate: backend `go build ./... && go vet ./... && go test ./...`; from web/ `npx tsc -b`,
    `npx vitest run` (incl. the parity test), `npm run build` (assertLazyChunks active; restore
    internal/web/dist/.gitkeep if the build wipes it). All green. Commit in feature-scoped units following the
    risk-first order (e.g. `feat(29): ValidateInlineTag`, `feat(29): okf.RewriteHashtags + Lezer offset-parity`,
    `feat(29): body-inclusive tag enumeration`, `fix(29): rename/merge/delete rewrite body #tags server-side`,
    `feat(29): preview body-rewrite scope`, `feat(29): TR-06 stale-body detect+report`). Stage files
    individually. Leave the SUMMARY for execute-phase's SUMMARY step.
  </action>
  <acceptance_criteria>
    - backend `go build/vet/test ./...` exits 0; from web/ `npx tsc -b` + `npx vitest run` + `npm run build` exit 0
    - `internal/web/dist/.gitkeep` present after the build; `git status` clean but for planning artifacts
    - commits are feature-scoped `(29)` in the risk-first order
  </acceptance_criteria>
</task>

</tasks>

<verification>
- Backend at HEAD: `go build ./... && go vet ./... && go test ./...` — all green (incl. okf RewriteHashtags offset-parity, ValidateInlineTag, pages body-scan + goldens + stale-open-client 409).
- Frontend at HEAD (from web/): `npx tsc -b` clean; `npx vitest run` all pass (incl. hashtag.parity.test.ts); `npm run build` clean.
- The regression golden: rename #ops→#devops on a body-#ops page → reconcile on next save → `ops` does NOT reappear (byte-stable). Equivalent for merge/delete/body-only.
- The parity test fails CI if the Go scanner drifts from the Lezer corpus (single-sourced, offset-level).
- graph/query.go still reads NO bodies (invariant intact); enumeration is in the pages service.
- Preview distinguishes body-affected pages; TR-06 detect+report present; ROADMAP over-claim softened in the SUMMARY.
</verification>

<success_criteria>
1. TR-01/TR-02: okf.RewriteHashtags is region-aware (full Lezer exclusion set), conservative, byte-stable, and pinned to the Lezer grammar by an offset-level whole-document single-sourced parity test that fails CI on drift.
2. TR-03: ValidateInlineTag gates rename+merge (400); rename/merge/delete rewrite body #tags AND frontmatter in one byte-stable batched commit; body-only pages are written; the regression golden + stale-open-client 409 pass.
3. TR-04: body-inclusive enumeration lives in the pages service (SEC-01 resolver), graph/query.go's no-body-reads invariant holds; body-only pages found; fenced/trashed excluded.
4. TR-05: preview + admin UI surface the body-rewrite scope.
5. TR-06: detect+report of pre-fix stale-body corruption; the ROADMAP over-claim softened honestly.
6. Full suites green; feature-scoped commits; no Phase-28 surface disturbed.
</success_criteria>
