---
author: architect
responsible: architect
phase: 31
phase_type: implementation
status: Ready for execution
hard_bar: false
security_relevant: true
design_fork: false
wave: 1
depends_on: []
files_modified:
  - internal/server/handlers_tags_admin.go
  - internal/server/handlers_tags_admin_test.go
  - internal/server/router.go
  - internal/audit/audit.go
  - web/src/api/client.ts
  - web/src/components/admin/TagManagement.tsx
requirements: [TF-01]
autonomous: true
---

# Phase 31: Bulk Multi-Tag Operations - Plan

**Goal:** An admin adds and/or removes multiple tags across a selected set of pages
in ONE byte-stable preview→approve batch commit, reusing `pages.ApplyTagsBatch`.

**Requirements:** TF-01

## must_haves

```yaml
truths:
  - id: TF-01-1
    statement: "POST /admin/tags/bulk accepts a page-set spec (explicit paths ∪ filter-tags) + add_tags + remove_tags; preview=true returns per-page before/after tag sets + affected count without writing"
    verification: "go test ./internal/server/ -run TestBulkTag -count=1; grep 'handleTagBulk' internal/server/handlers_tags_admin.go"
  - id: TF-01-2
    statement: "Approve applies via ApplyTagsBatch (N pages → 1 commit), byte-stable (only frontmatter tags-region changes; BodyOld='' disables body rewrite), per-page stale (409) excludes without sinking batch"
    verification: "go test ./internal/server/ -run TestBulkTagApply -count=1; grep 'BodyOld: \"\"' internal/server/handlers_tags_admin.go or verify computeBulkTags passes empty bodyOld/bodyNew"
  - id: TF-01-3
    statement: "Admin-only RBAC (RequireRole(admin)) + nosurf CSRF inherited from admin route subgroup; each added tag server-side re-validated via ValidateTagCharset"
    verification: "go test ./internal/server/ -run TestBulkTagRBAC -count=1; grep 'ValidateTagCharset' internal/server/handlers_tags_admin.go in handleTagBulk"
  - id: TF-01-4
    statement: "Golden test: add [a,b] + remove [c] across 3 pages → correct per-page frontmatter in 1 commit; a stale page reported 'stale' and excluded, batch completes"
    verification: "go test ./internal/server/ -run TestBulkTagGolden -count=1 -v"
```

## Tasks

### Task 1: Backend — bulk handler + compute + route + audit (Wave 1)

<read_first>
- internal/server/handlers_tags_admin.go (the existing tag-admin handlers + buildTagApplyItems seam at :194-252 + request/response types at :37-72)
- internal/pages/batch.go (TagApplyItem struct at :37-63 + ApplyTagsBatch at :72-226 — verify BodyOld="" disables body channel)
- internal/server/router.go (admin tag route subgroup at :321-343 — register POST /admin/tags/bulk here)
- internal/audit/audit.go (ActionTagRename/Merge/Delete at :63-67 — add ActionTagBulk in this block)
- internal/okf/repair.go (ValidateTagCharset at :159 — the frontmatter charset gate to call for each added tag)
- internal/graph/query.go (PagesByTag at :428 — the existing-tag-filter query for page-set resolution)
- internal/server/handlers_tags_admin_test.go (the existing test patterns: newTagAdminServer, seedPage, doMutate, adminCookies at :179-229)
</read_first>

<action>
**1. Add `ActionTagBulk` audit constant** in `internal/audit/audit.go` after `ActionTagDelete` (:67):
```go
ActionTagBulk = "tag_bulk"
```

**2. Add request/response types** in `internal/server/handlers_tags_admin.go` after the existing tag types (:72):
```go
type tagBulkRequest struct {
	PagePaths  []string `json:"page_paths"`   // explicit selection
	FilterTags []string `json:"filter_tags"`  // existing-tag filter (union)
	AddTags    []string `json:"add_tags"`
	RemoveTags []string `json:"remove_tags"`
	Preview    bool     `json:"preview"`
}
// Reuses tagPreviewResponse (:58) and tagApplyResponse (:72) — no new response types.
```

**3. Add `computeBulkTags` compute function** in `handlers_tags_admin.go` after `computeDeleteTags` (:183). It captures `addTags`/`removeTags` via closure and returns `(current ∪ add) \ remove`, normalized + deduped:
```go
func computeBulkTags(addTags, removeTags []string) func(current []string) ([]string, bool) {
	addSet := make(map[string]bool)
	for _, t := range addTags {
		addSet[normalizeTag(t)] = true
	}
	removeSet := make(map[string]bool)
	for _, t := range removeTags {
		removeSet[normalizeTag(t)] = true
	}
	return func(current []string) ([]string, bool) {
		seen := make(map[string]bool)
		var result []string
		for _, t := range current {
			n := normalizeTag(t)
			if removeSet[n] {
				continue // removed
			}
			if !seen[n] {
				seen[n] = true
				result = append(result, t) // keep original (already normalized form)
			}
		}
		for _, t := range addTags {
			n := normalizeTag(t)
			if removeSet[n] {
				continue
			}
			if !seen[n] {
				seen[n] = true
				result = append(result, n)
			}
		}
		// changed = result differs from current (order-insensitive)
		changed := !sameTagSet(current, result)
		return result, changed
	}
}
```
Add a `sameTagSet(a, b []string) bool` helper that compares normalized sets (not order).

**4. Add `handleTagBulk` handler** in `handlers_tags_admin.go` after `handleTagDelete` (:505). Pattern mirrors `handleTagRename` (:265-353):
- Decode `tagBulkRequest`.
- Validate: at least one of `page_paths` or `filter_tags` must be non-empty; at least one of `add_tags` or `remove_tags` must be non-empty.
- Normalize + validate each `add_tags` entry via `normalizeTag` + `okf.ValidateTagCharset` (400 on invalid — same as rename `new_name` validation at :309-314).
- Normalize `remove_tags` + `filter_tags` via `normalizeTag` (no charset validation needed for removal — they are matched, not written).
- **Resolve the page set server-side**: start with `page_paths` (explicit), then for each `filter_tag`, call `h.graph.PagesByTag(ctx, tag)` and union the results. Dedupe the combined list. Sort for determinism.
- Call `h.buildTagApplyItems(ctx, pageSet, computeBulkTags(addTags, removeTags), "", "")` — `bodyOld=""` disables the body channel (frontmatter-only op per D-02).
- If `req.Preview`: return `tagPreviewResponse{AffectedPages: preview, TotalAffected: len(items)}` as JSON.
- If apply: call `h.pages.ApplyTagsBatch(ctx, items, actor)`, return `tagApplyResponse{Action: "bulk", Results: results}`.
- Audit: `h.audit.Record(ctx, audit.Event{Action: audit.ActionTagBulk, Actor: actor, Detail: fmt.Sprintf("%d pages, +%v -%v", len(items), addTags, removeTags)})` — non-fatal per existing pattern.

**5. Register the route** in `internal/server/router.go` after the delete route (:343):
```go
admin.Post("/admin/tags/bulk", h.handleTagBulk)
```

**6. Add tests** in `internal/server/handlers_tags_admin_test.go`:
- `TestBulkTagRBAC`: editor gets 403 on both preview and apply (mirror the existing RBAC test at :164-170).
- `TestBulkTagPreview`: seed 3 pages with tags, preview add `[a,b]` + remove `[c]` → `total_affected` = 3, per-page `current_tags`/`new_tags` correct.
- `TestBulkTagGolden` (TF-01 crit 4): seed 3 pages (`a.md` tags `[c,x]`, `b.md` tags `[c,y]`, `c.md` tags `[c,z]`), apply add `[a,b]` + remove `[c]` → verify each page's frontmatter tags via `pages.Get` (a.md → `[x,a,b]`, b.md → `[y,a,b]`, c.md → `[z,a,b]`), verify 1 commit (check git log count or the batch commit subject), verify graph rebuild shows `c` tag gone from these pages.
- `TestBulkTagStale`: seed 2 pages, preview, then edit one page between preview and apply → that page reports `stale`, the other applies, batch completes.
- `TestBulkTagFilterSelection`: seed pages with tag `ops`, use `filter_tags: ["ops"]` (no explicit paths) → preview resolves to all pages with `ops`.
- `TestBulkTagValidation`: add_tags with invalid charset (`<script>`) → 400; empty page set + empty add/remove → 400.
</action>

<acceptance_criteria>
- `grep 'ActionTagBulk = "tag_bulk"' internal/audit/audit.go` returns a match
- `grep 'func (h \*authHandlers) handleTagBulk' internal/server/handlers_tags_admin.go` returns a match
- `grep 'admin.Post("/admin/tags/bulk"' internal/server/router.go` returns a match
- `grep 'func computeBulkTags' internal/server/handlers_tags_admin.go` returns a match
- `grep 'ValidateTagCharset' internal/server/handlers_tags_admin.go` shows it called in handleTagBulk for add_tags
- `grep 'BodyOld.*""' internal/server/handlers_tags_admin.go` or the buildTagApplyItems call passes `"", ""` for bodyOld/bodyNew
- `go test ./internal/server/ -run TestBulkTag -count=1 -v` exits 0 with TestBulkTagRBAC, TestBulkTagPreview, TestBulkTagGolden, TestBulkTagStale, TestBulkTagFilterSelection, TestBulkTagValidation all passing
- `go build ./...` exits 0
- `go vet ./internal/server/ ./internal/audit/` exits 0
</acceptance_criteria>

### Task 2: Frontend — client API + BulkTagDialog + TagManagement button (Wave 2)

<read_first>
- web/src/api/client.ts (renameTag/mergeTag/deleteTag at :1387-1417 + TagPreview/TagApplyResponse/TagPreviewPage types at :1351-1385 + listTags at :1371 + getTree at :218 + TreeNode at :199-207)
- web/src/components/admin/TagManagement.tsx (the existing dialog pattern: RenameDialog at :101-168, MergeDialog at :170-241, DeleteDialog at :243-301 + TagTable with action buttons at :41-99)
- web/src/components/admin/TagManagement.css (existing dialog styles)
</read_first>

<action>
**1. Add `bulkTag` API function** in `web/src/api/client.ts` after `deleteTag` (:1417). Mirror the `renameTag`/`mergeTag`/`deleteTag` pattern:
```typescript
export async function bulkTag(opts: {
	page_paths: string[];
	filter_tags: string[];
	add_tags: string[];
	remove_tags: string[];
	preview?: boolean;
}): Promise<TagPreview | TagApplyResponse> {
	return mutate<TagPreview | TagApplyResponse>(
		"/api/v1/admin/tags/bulk",
		opts,
	);
}
```
No new types needed — reuse `TagPreview` (:1360) and `TagApplyResponse` (:1382).

**2. Add `BulkTagDialog` component** in `web/src/components/admin/TagManagement.tsx` after `DeleteDialog` (:301). The dialog has:
- **Page-set selector**: a section with two inputs:
  - "Filter by tag" — a multi-select using the existing `listTags` vocabulary (render as a simple comma-separated input or a chip-picker if one exists in the codebase; check for an existing MultiSelect/picker component first). Each selected filter-tag is added to `filter_tags`.
  - "Or select pages" — a checkbox list over the page tree from `getTree()` (flatten `TreeNode[]` to page paths: `type === "page"`). Each checked page is added to `page_paths`.
  - The two are unioned (the server combines them; the client sends both arrays).
- **Tag operations**: two inputs:
  - "Tags to add" — a tag input (autocomplete from `listTags` vocabulary; free-text allowed for new tags).
  - "Tags to remove" — a tag input (autocomplete from `listTags` vocabulary).
- **Preview button**: calls `bulkTag({page_paths, filter_tags, add_tags, remove_tags, preview: true})` → displays the `TagPreview` results table (per-page `page_path`, `current_tags → new_tags`, reusing the existing preview table layout from RenameDialog/MergeDialog).
- **Approve button**: calls `bulkTag({page_paths, filter_tags, add_tags, remove_tags})` → displays the `TagApplyResponse` results (per-page status: applied/stale/notfound). On success, invalidates the `TAGS_KEY` query (`queryClient.invalidateQueries({queryKey: TAGS_KEY})`).
- **Cancel button**: closes the dialog.

**3. Add "Bulk add/remove" button** to `TagTable` in `TagManagement.tsx` (:41-99), beside the existing rename/merge/delete action buttons. It opens `BulkTagDialog` via a `useState` slot (`const [bulkOpen, setBulkOpen] = useState(false)`), mirroring how `renameTarget`/`mergeSource`/`deleteTarget` work.

**4. Add CSS** for the BulkTagDialog in `TagManagement.css` — mirror the existing dialog styles (the `.tagmgmt-dialog` patterns used by RenameDialog/MergeDialog/DeleteDialog). The page-checkbox list should be scrollable with a max-height (~300px) since the tree can be long.
</action>

<acceptance_criteria>
- `grep 'export async function bulkTag' web/src/api/client.ts` returns a match
- `grep 'function BulkTagDialog' web/src/components/admin/TagManagement.tsx` returns a match
- `grep 'bulkTag' web/src/components/admin/TagManagement.tsx` returns a match (the dialog calls the API)
- `grep 'setBulkOpen' web/src/components/admin/TagManagement.tsx` returns a match (the button opens the dialog)
- `cd web && npx tsc --noEmit` exits 0 (TypeScript compiles)
- `cd web && npx vite build` exits 0 (production build succeeds)
- The BulkTagDialog renders a preview table with per-page current_tags → new_tags (visible in the component structure)
- The approve flow calls `queryClient.invalidateQueries` with `TAGS_KEY` (grep for `invalidateQueries` near `bulkTag` usage)
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** An admin can add `[a,b]` and remove `[c]` across 3 selected pages in one preview→approve operation; the result is byte-stable (only frontmatter tags change), 1 commit, per-page 409 excluded, audit-logged. The golden test proves it. The frontend dialog renders the preview and approve flow.
- **Byte-stability:** verified by the golden test reading frontmatter via `pages.Get` after apply — only the tags region changes, body untouched (`BodyOld=""`).
- **Security:** admin-only RBAC + CSRF inherited from the route subgroup; added tags re-validated via `ValidateTagCharset`. No body splicing → no XSS surface. The bulk endpoint is behind the same `RequireRole(admin)` + nosurf as rename/merge/delete.
- **No new write path:** the op routes through the existing `buildTagApplyItems` + `ApplyTagsBatch` seam — verified by grep showing `handleTagBulk` calls `h.buildTagItems` and `h.pages.ApplyTagsBatch`, never a parallel commit path.