---
author: architect
responsible: architect
phase: 32
phase_type: implementation
status: Ready for execution
hard_bar: false
security_relevant: true
design_fork: true
wave: 1
depends_on: []
files_modified:
  - internal/tagmeta/ (new package)
  - internal/store/migrations/0011_tag_meta.sql (new migration)
  - internal/pages/tree.go (add Tags/ to isSkippedDir)
  - internal/server/handlers_tags_admin.go
  - internal/server/handlers_tags_admin_test.go
  - internal/server/router.go
  - internal/audit/audit.go
  - web/src/lib/cm/widgets.ts
  - web/src/lib/cm/livePreview.ts
  - web/src/api/client.ts
  - web/src/components/admin/TagManagement.tsx
  - web/src/components/admin/TagManagement.css
requirements: [TF-02]
autonomous: true
---

# Phase 32: Tag Metadata Store (color/description) - Plan

**Goal:** Introduce a net-new, committed, on-disk tag-metadata store (`Tags/*.md`
files, files-as-truth) with a rebuildable SQLite cache + migration; let an admin set
a tag's color + description, surfaced in the admin list and on rendered CM6 badges.

**Requirements:** TF-02

## must_haves

```yaml
truths:
  - id: TF-02-1
    statement: "Tag metadata persists on disk as Tags/{tag}.md files (YAML frontmatter: tag, color, description); a SQLite tag_meta cache is fully rebuildable from them (delete-cache → rebuild = identical metadata); a 0011_tag_meta.sql migration creates the cache table"
    verification: "go test ./internal/tagmeta/ -count=1 -v; test -f internal/store/migrations/0011_tag_meta.sql"
  - id: TF-02-2
    statement: "POST /admin/tags/meta sets/clears a tag's color + description via admin-only, CSRF-protected, server-validated endpoint (color = hex #RRGGBB/#RGB or fixed palette token; description single-line ≤200 chars, HTML-metachar-safe)"
    verification: "go test ./internal/server/ -run TestTagMeta -count=1 -v; grep 'handleTagMeta' internal/server/handlers_tags_admin.go"
  - id: TF-02-3
    statement: "CM6 HashtagWidget reflects a set color via style.color (createElement+textContent DOM preserved, no innerHTML); the XSS invariant holds"
    verification: "grep 'style.color' web/src/lib/cm/widgets.ts; cd web && npx tsc --noEmit; cd web && npx vitest run src/lib/cm/widgets.test.ts"
  - id: TF-02-4
    statement: "Absent metadata degrades gracefully (tag renders as today; feature is additive; a page's tags fully usable with the store empty)"
    verification: "go test ./internal/tagmeta/ -run TestEmptyStore -count=1 -v"
```

## Tasks

### Task 1: Backend — tagmeta package + migration + tree skip (Wave 1)

<read_first>
- internal/pages/templates.go (the Templates/ pattern: templatesDir const at :22, ListTemplates at :42, repo.Tree + prefix filter + repo.Resolve SEC-01)
- internal/store/migrations.go (embed + Migrate harness at :13-65)
- internal/store/migrations/0009_graph.sql (the "derived cache, never truth" migration tone)
- internal/store/migrations/0010_tag_suggestions.sql (a recent migration for tone/pattern)
- internal/graph/rebuild.go (RebuildGraph at :33 — the rebuild-from-disk + parity pattern)
- internal/graph/rebuild_test.go (TestDerivedOnly_RebuildReproducesAdjacency at :23 — the parity test shape)
- internal/pages/tree.go (isSkippedDir at :121-137 — add Tags/ here)
- internal/repo/files.go (Repo.Read, Repo.Write, Repo.Resolve, Repo.Tree — the SEC-01 path)
- internal/okf/repair.go (ValidateTagCharset at :159 — validate the tag name)
</read_first>

<action>
**1. Create `internal/tagmeta/` package** with:
- `store.go` — the `Store` struct (holds `*repo.Repo` + `*store.Store` + the single-writer commit enqueue, same as `pages.Service`). Methods:
  - `GetMeta(ctx, tag) (Meta, bool)` — reads from the SQLite cache; returns false if no row.
  - `AllMeta(ctx) ([]Meta, error)` — reads all rows from the cache.
  - `SetMeta(ctx, tag, color, description string) error` — validates inputs, writes `Tags/{tag}.md` via `repo.Write` + commits through the single-writer path (`EnqueueCommit`), then upserts the cache row. The file shape:
    ```markdown
    ---
    tag: {tag}
    color: "{color}"
    description: "{description}"
    ---
    # {tag}
    ```
    (if color/description are both empty, either write an empty-metadata file or delete the file — the planner picks; recommend delete the file when both empty + no body, so absent = graceful).
  - `ClearMeta(ctx, tag) error` — clears color/description (writes empty values or deletes the file).
  - `RebuildTagMeta(ctx) error` — walks `Tags/*.md` via `repo.Tree()` + prefix filter (`Tags/`) + `repo.Read` + `okf.Parse`, parses frontmatter, deletes all `tag_meta` rows, re-inserts. This is the parity-harness: delete-cache → rebuild = identical.
- `meta.go` — `Meta{Tag, Color, Description string}` struct.
- `validate.go` — `ValidateColor(color string) error` (hex `#RRGGBB`/`#RGB` or a fixed palette token set: `red`, `blue`, `green`, `amber`, `purple`, `gray` — the exact set is the planner's call; use a map for O(1) lookup). `ValidateDescription(desc string) error` (single-line, ≤200 chars, rejects `<>&"'` HTML metachars — or escapes them; recommend reject for simplicity + safety).

**2. Create `internal/store/migrations/0011_tag_meta.sql`**:
```sql
-- 0011_tag_meta: tag metadata cache (Phase 32, TF-02).
-- Operational/derived data ONLY — the canonical tag metadata lives on disk as
-- Tags/{tag}.md files (files-as-truth). Deleting this table and running
-- RebuildTagMeta reproduces identical rows. SQLite is NEVER the source of truth
-- (mirrors 0009_graph.sql tone).
CREATE TABLE IF NOT EXISTS tag_meta (
    tag         TEXT PRIMARY KEY,
    color       TEXT NOT NULL DEFAULT '',
    description TEXT NOT NULL DEFAULT ''
);
```

**3. Add `Tags/` to `isSkippedDir`** in `internal/pages/tree.go:123-137`:
```go
// The tag-metadata store (Tags/ at the repo root) holds per-tag Markdown metadata
// files — not wiki pages. It must not surface as a navigable folder.
if slashRel == "Tags" || strings.HasPrefix(slashRel, "Tags/") {
    return true
}
```

**4. Handle `Tags/` in graph/search rebuilds.** The graph rebuild (`graph/rebuild.go:47`) and search rebuild (`search/rebuild.go`) walk all `.md` files excluding only trash. `Tags/*.md` files would be indexed as pages (they ARE valid Markdown). Two options:
  - **(a) Skip `Tags/` in graph/search rebuilds** (add a `isTagMeta` prefix check alongside `isTrashed`). Cleaner — tag metadata is not page content.
  - **(b) Accept `Tags/*.md` as indexed pages** (they show in search + graph — arguably fine since they're human-readable tag docs).
  - **Recommend (a)** — `Tags/` is a reserved folder like `Templates/` (note: `Templates/` is NOT currently skipped by graph/search, which is a pre-existing quirk; skipping `Tags/` is the right call for a metadata store). Add `isTagMetaFile` checks to both `graph/rebuild.go` and `search/rebuild.go` (a `tagsPrefix = "Tags"` const, skip if `strings.HasPrefix(path, tagsPrefix+"/")`). This is a 2-line addition to each rebuild, mirroring the `isTrashed` check.

**5. Write tests** in `internal/tagmeta/store_test.go`:
- `TestSetGetMeta` — set color + description for "deploy", GetMeta returns them.
- `TestRebuildTagMeta` — seed `Tags/deploy.md` + `Tags/ops.md`, rebuild, verify cache rows; delete cache, rebuild, verify identical.
- `TestRebuildParity` — the delete-cache → rebuild = identical test (mirror `TestDerivedOnly_RebuildReproducesAdjacency`).
- `TestEmptyStore` — no `Tags/` folder → AllMeta returns empty, GetMeta returns false. Graceful.
- `TestValidateColor` — valid hex (`#4caf50`, `#fff`), valid palette token (`red`), invalid (`url(...)`, `var(--x)`, `<script>`, empty CSS) → reject.
- `TestValidateDescription` — valid short text, rejects newlines, rejects `<>&"'`, rejects >200 chars.
- `TestSetMetaWritesFile` — after SetMeta, `Tags/{tag}.md` exists on disk with correct frontmatter.
- `TestClearMetaDeletesFile` — after ClearMeta (both empty), file is gone.
</action>

<acceptance_criteria>
- `test -d internal/tagmeta/` (package exists)
- `test -f internal/tagmeta/store.go` and `grep 'func.*Store.*SetMeta' internal/tagmeta/store.go` returns a match
- `test -f internal/tagmeta/meta.go` and `grep 'type Meta struct' internal/tagmeta/meta.go` returns a match
- `test -f internal/store/migrations/0011_tag_meta.sql` and `grep 'CREATE TABLE IF NOT EXISTS tag_meta' internal/store/migrations/0011_tag_meta.sql` returns a match
- `grep 'Tags' internal/pages/tree.go` returns a match (isSkippedDir updated)
- `go test ./internal/tagmeta/ -count=1 -v` — all tests pass
- `go build ./...` exits 0
- `go vet ./internal/tagmeta/ ./internal/store/ ./internal/pages/` exits 0
</acceptance_criteria>

### Task 2: Backend — admin endpoint + route + audit (Wave 1, depends on Task 1)

<read_first>
- internal/server/handlers_tags_admin.go (handleTagList at :507-529, request/response types at :37-72, the admin handler pattern)
- internal/server/router.go (admin tag route subgroup at :321-343)
- internal/audit/audit.go (ActionTagBulk at :68 — add ActionTagMeta nearby)
- internal/tagmeta/store.go + meta.go (from Task 1)
</read_first>

<action>
**1. Add `ActionTagMeta` audit constant** in `internal/audit/audit.go` after `ActionTagBulk`:
```go
ActionTagMeta = "tag_meta"
```

**2. Add request/response types** in `handlers_tags_admin.go`:
```go
type tagMetaRequest struct {
	Tag         string `json:"tag"`
	Color       string `json:"color"`
	Description string `json:"description"`
}
type tagMetaResponse struct {
	Tag         string `json:"tag"`
	Color       string `json:"color"`
	Description string `json:"description"`
}
```

**3. Extend `handleTagList`** (`handlers_tags_admin.go:507-529`) to include color + description per tag. The `TagEntry` response gains `color` + `description` fields (from `tagmeta.AllMeta`). The handler reads all metadata in one call and merges it into the tag list by tag name. Tags with no metadata get empty strings (graceful).

**4. Add `handleTagMeta`** handler — `POST /admin/tags/meta`:
- Decode `tagMetaRequest`.
- Validate `tag` via `normalizeTag` + `okf.ValidateTagCharset` (400 on invalid).
- Validate `color` via `tagmeta.ValidateColor` (400 on invalid — empty string = clear color).
- Validate `description` via `tagmeta.ValidateDescription` (400 on invalid — empty string = clear description).
- Call `h.tagmeta.SetMeta(ctx, tag, color, description)` (writes the `Tags/{tag}.md` file + commits + updates cache).
- Audit: `h.audit.Record(ctx, audit.Event{Action: audit.ActionTagMeta, Actor: actor, Detail: fmt.Sprintf("tag=%s color=%s", tag, color)})`.
- Return `tagMetaResponse{Tag, Color, Description}`.

**5. Register the route** in `router.go` after the bulk route:
```go
admin.Post("/admin/tags/meta", h.handleTagMeta)
```

**6. Wire `tagmeta.Store` into `authHandlers`** — add a `tagmeta *tagmeta.Store` field to `authHandlers` (or wherever the other services are wired), initialized in the server setup (the planner finds the server setup site — likely in `server.go` or `router.go` where `pages`/`graph`/`audit` are wired).

**7. Write tests** in `handlers_tags_admin_test.go`:
- `TestTagMetaRBAC` — editor gets 403 on POST /admin/tags/meta.
- `TestTagMetaSetGet` — admin sets color `#4caf50` + description `"Deploy ops"` for "deploy"; GET /admin/tags returns the tag with color + description; verify `Tags/deploy.md` exists on disk.
- `TestTagMetaValidation` — invalid color (`url(x)`) → 400; invalid description (newline, >200 chars, `<script>`) → 400; invalid tag (`a/b`) → 400.
- `TestTagMetaClear` — set metadata, then set with empty color + description → file deleted, GET /admin/tags shows empty.
- `TestTagMetaAbsent` — GET /admin/tags with no Tags/ folder → tags render with empty color/description (graceful).
</action>

<acceptance_criteria>
- `grep 'ActionTagMeta = "tag_meta"' internal/audit/audit.go` returns a match
- `grep 'func (h \*authHandlers) handleTagMeta' internal/server/handlers_tags_admin.go` returns a match
- `grep 'admin.Post("/admin/tags/meta"' internal/server/router.go` returns a match
- `grep 'tagmeta' internal/server/handlers_tags_admin.go` returns a match (the handler uses the tagmeta store)
- `go test ./internal/server/ -run TestTagMeta -count=1 -v` — all 5 tests pass
- `go build ./...` exits 0
</acceptance_criteria>

### Task 3: Frontend — badge color + client API + admin UI (Wave 2)

<read_first>
- web/src/lib/cm/widgets.ts (HashtagWidget at :327-359 — add optional color parameter)
- web/src/lib/cm/livePreview.ts (HashtagWidget construction at :294 — pass color)
- web/src/lib/cm/theme.ts (.cm-hashtag at :122-128 — default color is var(--color-accent))
- web/src/api/client.ts (TagEntry at :1366-1369, listTags at :1371-1375, renameTag pattern at :1387-1396)
- web/src/components/admin/TagManagement.tsx (TagTable at :41-99, dialogs at :101-301)
- web/src/components/admin/TagManagement.css
</read_first>

<action>
**1. Extend `HashtagWidget`** in `widgets.ts:335`:
- Add optional `color?: string` parameter to the constructor.
- In `toDOM()`, after setting `span.className` and `span.textContent`, if `this.color` is set, apply `span.style.color = this.color`. The DOM stays `createElement` + `textContent` — NO `innerHTML`. The color is a validated hex/palette token (validated server-side; the client trusts the server's response).
- Update `eq()` to compare `this.color === other.color` (so color changes re-render the widget).

**2. Pass color from `livePreview.ts:294`** — the `HashtagWidget` construction site. The color comes from the tag metadata cache. The editor needs access to the metadata — either:
  - Pass a `tagMeta: Map<string, string>` (tag → color) to the editor via props, fetched from the `GET /admin/tags` response (which now includes color).
  - Or the `livePreview.ts` receives a `tagColors` function/map from the `LivePreviewEditor` component.
  - The planner picks the wiring; recommend a `tagColors: Record<string, string>` prop on `LivePreviewEditor` (or the component that owns the `livePreview` extension), fetched via `listTags()` (which now includes color), passed down to the `livePreview.ts` decoration builder. The decoration builder looks up `tagColors[tag]` when constructing `new HashtagWidget(tag, color)`.
  - **Fallback:** if no color is set for a tag, `HashtagWidget` uses the theme default (`var(--color-accent)`) — pass `undefined` or empty string, which means "no inline style" → the CSS class `.cm-hashtag` default applies.

**3. Extend `TagEntry` in `client.ts:1366`**:
```typescript
export interface TagEntry {
  tag: string;
  count: number;
  color?: string;        // hex or palette token; absent = default
  description?: string;  // single-line; absent = none
}
```

**4. Add `setTagMeta` API function** in `client.ts` after `bulkTag`:
```typescript
export async function setTagMeta(opts: {
  tag: string;
  color: string;
  description: string;
}): Promise<void> {
  await mutate<void>("/api/v1/admin/tags/meta", opts);
}
```

**5. Add metadata editor to `TagManagement.tsx`**:
- Extend the tag table to show a color swatch (a small colored dot or the tag name in its color) + description (as a tooltip or a second column) per tag row.
- Add an "Edit metadata" action per tag row (a small pencil/color icon button beside the existing rename/merge/delete buttons). Clicking opens a `TagMetaDialog`.
- `TagMetaDialog`: inputs for color (a color picker constrained to hex + the palette tokens — use `<input type="color">` for hex + a `<select>` for palette tokens, or a simple text input with validation) and description (single-line text input with a max-length=200). Save calls `setTagMeta({tag, color, description})`, invalidates `TAGS_KEY`. Cancel closes.
- The color swatch in the table: `style={{ color: tag.color || undefined }}` on the tag name span, or a small `<span className="tag-color-dot" style={{ backgroundColor: tag.color }} />` beside the name.

**6. Add CSS** for the color swatch + TagMetaDialog in `TagManagement.css`.
</action>

<acceptance_criteria>
- `grep 'color' web/src/lib/cm/widgets.ts` shows the HashtagWidget constructor accepts color
- `grep 'style.color' web/src/lib/cm/widgets.ts` returns a match (color applied via style, not innerHTML)
- `grep 'innerHTML' web/src/lib/cm/widgets.ts` in the HashtagWidget section returns NO match (the XSS invariant holds)
- `grep 'color' web/src/api/client.ts` shows TagEntry has color field
- `grep 'export async function setTagMeta' web/src/api/client.ts` returns a match
- `grep 'TagMetaDialog' web/src/components/admin/TagManagement.tsx` returns a match
- `cd web && npx tsc --noEmit` exits 0
- `cd web && npx vite build` exits 0
- `cd web && npx vitest run src/lib/cm/widgets.test.ts` exits 0 (existing widget tests still pass)
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** An admin sets a tag's color + description; the metadata persists on disk as `Tags/{tag}.md` (committed, portable); the SQLite cache rebuilds identically from disk; the CM6 badge reflects the color via `style.color` (no innerHTML); absent metadata degrades gracefully.
- **Files-as-truth:** verified by the rebuild parity test — delete cache, rebuild, identical rows.
- **Security:** admin-only RBAC + CSRF inherited; color validated to hex/palette subset (no CSS injection); description single-line + HTML-safe; badge DOM stays createElement+textContent (no innerHTML). This is the security-flagged phase — the color/description injection surface into badge DOM is the surface the milestone-close security review audits.
- **No-innerHTML invariant:** verified by grep showing `style.color` in HashtagWidget, no `innerHTML` in the badge construction path.
- **Shared-file coordination:** `widgets.ts` + `theme.ts` are also touched by Phase 36 (copy-code button). This phase sequences first (32 before 36 per the roadmap). The no-innerHTML invariant is shared.