---
author: architect
responsible: architect
phase: 33
phase_type: implementation
status: Ready for execution
hard_bar: false
security_relevant: false
design_fork: false
wave: 1
depends_on: [32]
files_modified:
  - internal/tagmeta/store.go
  - internal/tagmeta/meta.go
  - internal/tagmeta/store_test.go
  - internal/store/migrations/0012_tag_meta_parent.sql
  - internal/server/handlers_tags_admin.go
  - internal/server/handlers_tags_admin_test.go
  - web/src/api/client.ts
  - web/src/components/admin/TagManagement.tsx
requirements: [TF-03]
autonomous: true
---

# Phase 33: Tag Hierarchy - Plan

**Goal:** Tags form a cycle-safe parent/child hierarchy in the Phase-32 store, shown
grouped in the admin list — display/organization only, no page rewrites.

**Requirements:** TF-03

## must_haves

```yaml
truths:
  - id: TF-03-1
    statement: "A tag may declare a parent (or none); the store rejects cycles and self-parenting server-side (a tag cannot be its own ancestor); root/orphan tags are valid"
    verification: "go test ./internal/tagmeta/ -run TestCycle -count=1 -v; go test ./internal/server/ -run TestTagHierarchy -count=1 -v"
  - id: TF-03-2
    statement: "The admin tag list displays tags grouped parent→children; hierarchy edits use the same admin-only + CSRF + validated endpoint as TF-02"
    verification: "grep 'parent' web/src/api/client.ts; grep 'grouped\|children\|parent' web/src/components/admin/TagManagement.tsx"
  - id: TF-03-3
    statement: "Hierarchy is display-only: no auto-apply of parent tags, no descendant-expanding search, no page bytes changed by a hierarchy edit"
    verification: "grep -r 'parent' internal/pages/ internal/search/ returns no auto-apply or search-expansion logic"
  - id: TF-03-4
    statement: "Rebuildable-from-disk parity holds (hierarchy lives in the TF-02 store; parent field in frontmatter + cache)"
    verification: "go test ./internal/tagmeta/ -run TestRebuildParityWithParent -count=1 -v"
```

## Tasks

### Task 1: Backend — parent field + cycle detection + migration (Wave 1)

<read_first>
- internal/tagmeta/store.go (from Phase 32 — SetMeta, GetMeta, AllMeta, RebuildTagMeta)
- internal/tagmeta/meta.go (from Phase 32 — Meta struct)
- internal/tagmeta/store_test.go (from Phase 32 — test patterns)
- internal/tagmeta/validate.go (from Phase 32 — ValidateColor, ValidateDescription)
- internal/store/migrations/0011_tag_meta.sql (from Phase 32 — the base migration)
- internal/store/migrations.go (Migrate harness — add 0012)
</read_first>

<action>
**1. Extend the `Meta` struct** in `internal/tagmeta/meta.go`:
```go
type Meta struct {
	Tag         string
	Color       string
	Description string
	Parent      string  // NEW: the parent tag name (normalized); "" = root
}
```

**2. Create `internal/store/migrations/0012_tag_meta_parent.sql`**:
```sql
-- 0012_tag_meta_parent: add parent column to tag_meta (Phase 33, TF-03).
-- Additive: existing rows get '' (root). The parent field is part of the
-- files-as-truth Tags/*.md frontmatter; this column is the rebuildable cache.
ALTER TABLE tag_meta ADD COLUMN parent TEXT NOT NULL DEFAULT '';
```

**3. Extend `tagmeta.Store`** in `store.go`:
- `SetMeta` gains a `parent string` parameter. It writes the `parent` field into the `Tags/{tag}.md` frontmatter and upserts the cache row with the parent value.
- `GetMeta` / `AllMeta` read the `parent` column from the cache.
- `RebuildTagMeta` parses the `parent` frontmatter key and populates the cache column.
- Add a `checkCycle(ctx, tag, parent) error` method: walks the parent chain from `parent` up to root. If `tag` is revisited → cycle (reject). If the chain exceeds a depth cap (100) → corrupted store (reject). Self-parenting (`parent == tag`) is rejected directly.

**4. Add `ValidateParent` to `validate.go`**: validates the parent is a valid tag name (`ValidateTagCharset`) OR empty. Does NOT check existence (a non-existent parent is treated as root per D-02b). The cycle check is separate (run after the parent is resolved, before the write).

**5. Write tests** in `internal/tagmeta/store_test.go`:
- `TestSetMetaWithParent` — set parent on "deploy" → "ops"; GetMeta returns parent="ops"; `Tags/deploy.md` frontmatter has `parent: ops`.
- `TestCycleDetection_SelfParent` — set parent="deploy" on "deploy" → rejected with cycle error.
- `TestCycleDetection_Indirect` — build a chain ops→infra→deploy, then try to set parent="deploy" on "ops" → rejected (cycle).
- `TestCycleDetection_DeepChain` — build a 50-tag chain, set parent at the end → valid (no cycle); try to close the loop → rejected.
- `TestNonExistentParent` — set parent="ghost" (doesn't exist) → accepted (treated as root); GetMeta returns parent="ghost" (the value is stored; the UI treats non-existent as root).
- `TestRebuildParityWithParent` — seed Tags/*.md with parent fields, rebuild, verify cache; delete cache, rebuild, verify identical.
</action>

<acceptance_criteria>
- `test -f internal/store/migrations/0012_tag_meta_parent.sql`
- `grep 'ALTER TABLE tag_meta ADD COLUMN parent' internal/store/migrations/0012_tag_meta_parent.sql` returns a match
- `grep 'Parent' internal/tagmeta/meta.go` returns a match
- `grep 'checkCycle\|cycle' internal/tagmeta/store.go` returns a match (cycle detection exists)
- `go test ./internal/tagmeta/ -count=1 -v` — all tests pass (including new cycle tests)
- `go build ./...` exits 0
</acceptance_criteria>

### Task 2: Backend — admin endpoint + cycle check wire-in (Wave 1, depends on Task 1)

<read_first>
- internal/server/handlers_tags_admin.go (handleTagMeta from Phase 32, handleTagList)
- internal/server/router.go (admin tag route subgroup)
- internal/tagmeta/store.go (from Task 1 — SetMeta with parent, checkCycle)
</read_first>

<action>
**1. Extend `tagMetaRequest`** in `handlers_tags_admin.go`:
```go
type tagMetaRequest struct {
	Tag         string `json:"tag"`
	Color       string `json:"color"`
	Description string `json:"description"`
	Parent      string `json:"parent"`  // NEW
}
```

**2. Extend `handleTagMeta`**:
- Parse `parent` from the request.
- Validate `parent` via `tagmeta.ValidateParent` (valid tag name or empty).
- Run `tagmeta.checkCycle(ctx, tag, parent)` — reject with 400 if cycle.
- Call `h.tagmeta.SetMeta(ctx, tag, color, description, parent)`.

**3. Extend `handleTagList`** — include `parent` per tag in the response. The `TagEntry` response gains a `parent` field (from `AllMeta`).

**4. Write tests** in `handlers_tags_admin_test.go`:
- `TestTagHierarchySetParent` — admin sets parent="ops" on "deploy"; GET /admin/tags shows deploy with parent="ops".
- `TestTagHierarchyCycle` — build ops→infra, then try to set parent="ops" on "infra" → 400 (cycle).
- `TestTagHierarchySelfParent` — set parent="deploy" on "deploy" → 400.
- `TestTagHierarchyRootTag` — set parent="" on "deploy" → valid (root tag).
</action>

<acceptance_criteria>
- `grep 'Parent' internal/server/handlers_tags_admin.go` returns a match (request type + handler)
- `grep 'checkCycle\|cycle' internal/server/handlers_tags_admin.go` returns a match
- `go test ./internal/server/ -run TestTagHierarchy -count=1 -v` — all 4 tests pass
- `go build ./...` exits 0
</acceptance_criteria>

### Task 3: Frontend — grouped admin rendering (Wave 2)

<read_first>
- web/src/api/client.ts (TagEntry from Phase 32 — add parent field)
- web/src/components/admin/TagManagement.tsx (TagTable from Phase 32 — add grouped rendering)
- web/src/components/admin/TagManagement.css
</read_first>

<action>
**1. Extend `TagEntry`** in `client.ts`:
```typescript
export interface TagEntry {
  tag: string;
  count: number;
  color?: string;
  description?: string;
  parent?: string;  // NEW
}
```

**2. Extend `setTagMeta`** in `client.ts` — add `parent: string` to the opts.

**3. Add grouped rendering to `TagTable`** in `TagManagement.tsx`:
- Compute a grouped view from the flat tag list: tags with no parent are roots; tags with a parent are children. A tag whose parent doesn't exist in the list is treated as root (orphaned).
- Render roots as section headers (or top-level rows), children indented beneath their parent. Use a simple indentation or a tree-like layout (CSS padding-left based on depth — but depth is single-level in v1.6, so a parent→children grouping is enough; nested hierarchy display is a Future item, but the data model supports it).
- The TagMetaDialog (from Phase 32) gains a "Parent" input (a select/autocomplete from the tag vocabulary — exclude the tag itself to prevent self-parenting in the UI; the server cycle check is the authoritative gate).

**4. Add CSS** for the indented/grouped rows in `TagManagement.css`.
</action>

<acceptance_criteria>
- `grep 'parent' web/src/api/client.ts` returns a match (TagEntry + setTagMeta opts)
- `grep 'parent\|children\|grouped' web/src/components/admin/TagManagement.tsx` returns a match
- `cd web && npx tsc --noEmit` exits 0
- `cd web && npx vite build` exits 0
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** A tag declares a parent; the store rejects cycles + self-parenting; the admin list shows grouped tags; hierarchy is display-only (no page bytes changed); rebuild-from-disk parity holds.
- **Display-only:** verified by grep showing no auto-apply or search-expansion logic in `internal/pages/` or `internal/search/` referencing `parent`.
- **Cycle safety:** verified by the cycle tests (self-parent, indirect cycle, deep chain).
- **Rebuild parity:** verified by `TestRebuildParityWithParent`.