---
author: architect
responsible: architect
phase: 35
phase_type: implementation
status: Ready for execution
hard_bar: true
security_relevant: true
design_fork: true
wave: 1
depends_on: [31]
files_modified:
  - internal/store/migrations/0013_tag_op_journal.sql
  - 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-05]
autonomous: true
---

# Phase 35: Tag-Operation Undo - Plan

**Goal:** Undo the last tag-admin operation (rename/merge/delete/bulk) by replaying
its inverse through `ApplyTagsBatch` — byte-stable, one commit, per-page 409-guarded,
forward-commit — backed by a dedicated SQLite op-journal.

**Requirements:** TF-05

## must_haves

```yaml
truths:
  - id: TF-05-1
    statement: "Each tag-admin op records a minimum invertible record durably (op type, old/new names, affected pages, each page's pre-op frontmatter tags + body-affected flag) in a dedicated tag_op_journal SQLite table"
    verification: "test -f internal/store/migrations/0013_tag_op_journal.sql; go test ./internal/server/ -run TestJournal -count=1 -v"
  - id: TF-05-2
    statement: "POST /admin/tags/undo replays the inverse via ApplyTagsBatch (byte-stable, 1 commit); a page edited since the op is reported stale and skipped (409-guarded, never clobbered)"
    verification: "go test ./internal/server/ -run TestUndo -count=1 -v"
  - id: TF-05-3
    statement: "Undo is admin-only + CSRF + audited; forward-commit (never history rewrite); undo-of-undo possible or explicitly scoped out"
    verification: "grep 'ActionTagUndo' internal/audit/audit.go; go test ./internal/server/ -run TestUndoForward -count=1 -v"
  - id: TF-05-4
    statement: "Golden test: rename #ops→#devops, then undo → affected pages' frontmatter + body #tags byte-match pre-rename; an intervening-edit page skipped with a report"
    verification: "go test ./internal/server/ -run TestUndoGolden -count=1 -v"
```

## Tasks

### Task 1: Backend — op-journal table + journaling integration (Wave 1)

<read_first>
- internal/store/migrations/0010_tag_suggestions.sql (the staging-table pattern — JSON columns, operational data)
- internal/store/migrations.go (embed + Migrate)
- internal/server/handlers_tags_admin.go (all four handlers: handleTagRename :331, handleTagMerge :420, handleTagDelete :503, handleTagBulk :580 — hook journaling after apply)
- internal/pages/batch.go (TagApplyItem, ApplyTagsBatch — the apply path)
</read_first>

<action>
**1. Create `internal/store/migrations/0013_tag_op_journal.sql`**:
```sql
-- 0013_tag_op_journal: tag-operation undo journal (Phase 35, TF-05).
-- Operational/derived data ONLY — the invertible record of each tag-admin op
-- (rename/merge/delete/bulk), used to undo the LAST op. NOT content (not
-- user-authored; machine-generated operational state). Mirrors 0010_tag_suggestions
-- tone: SQLite is appropriate for operational state, not for content.
-- Append-only: a new row is inserted after every successful tag-admin apply.
-- Only the LAST row is undoable in v1.6 (last-op undo).
CREATE TABLE IF NOT EXISTS tag_op_journal (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    op_type    TEXT NOT NULL,          -- "rename" | "merge" | "delete" | "bulk"
    op_detail  TEXT NOT NULL,          -- JSON: op-specific data
    pages      TEXT NOT NULL,          -- JSON: [{page_path, pre_op_tags, body_affected}]
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```

**2. Add journaling types** in `handlers_tags_admin.go`:
```go
type journalPage struct {
	PagePath     string   `json:"page_path"`
	PreOpTags    []string `json:"pre_op_tags"`
	BodyAffected bool     `json:"body_affected"`
}
type journalRecord struct {
	OpType   string        `json:"op_type"`
	OpDetail interface{}   `json:"op_detail"`  // {old_name, new_name} / {source, target} / {tag} / {add_tags, remove_tags}
	Pages    []journalPage `json:"pages"`
}
```

**3. Add a `journalOp` helper** on `authHandlers`:
```go
func (h *authHandlers) journalOp(ctx context.Context, opType string, opDetail interface{}, preview []tagPreviewPage) error {
	pages := make([]journalPage, len(preview))
	for i, p := range preview {
		pages[i] = journalPage{PagePath: p.PagePath, PreOpTags: p.CurrentTags, BodyAffected: p.BodyAffected}
	}
	rec := journalRecord{OpType: opType, OpDetail: opDetail, Pages: pages}
	detailJSON, _ := json.Marshal(rec.OpDetail)
	pagesJSON, _ := json.Marshal(rec.Pages)
	_, err := h.store.DB().ExecContext(ctx,
		`INSERT INTO tag_op_journal (op_type, op_detail, pages) VALUES (?, ?, ?)`,
		opType, string(detailJSON), string(pagesJSON))
	return err
}
```

**4. Hook journaling into each handler** — after a successful `ApplyTagsBatch` call, before returning the response:
- `handleTagRename` (:331): `h.journalOp(ctx, "rename", map[string]string{"old_name": oldName, "new_name": newName}, preview)`
- `handleTagMerge` (:420): `h.journalOp(ctx, "merge", map[string]string{"source": source, "target": target}, preview)`
- `handleTagDelete` (:503): `h.journalOp(ctx, "delete", map[string]string{"tag": tag}, preview)`
- `handleTagBulk` (:580): `h.journalOp(ctx, "bulk", map[string]interface{}{"add_tags": addTags, "remove_tags": removeTags}, preview)`

**5. Write tests** in `handlers_tags_admin_test.go`:
- `TestJournalRename` — apply a rename, verify a journal row exists with correct op_type + op_detail + pages.
- `TestJournalBulk` — apply a bulk op, verify the journal row.
</action>

<acceptance_criteria>
- `test -f internal/store/migrations/0013_tag_op_journal.sql`
- `grep 'CREATE TABLE IF NOT EXISTS tag_op_journal' internal/store/migrations/0013_tag_op_journal.sql` returns a match
- `grep 'journalOp' internal/server/handlers_tags_admin.go` returns a match
- `grep 'journalOp\|journal' internal/server/handlers_tags_admin.go` shows it called in all 4 handlers
- `go test ./internal/server/ -run TestJournal -count=1 -v` — tests pass
- `go build ./...` exits 0
</acceptance_criteria>

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

<read_first>
- internal/server/handlers_tags_admin.go (the four handlers + journalOp from Task 1)
- internal/pages/batch.go (TagApplyItem, ApplyTagsBatch — the inverse rides this)
- internal/okf/hashtag.go (RewriteHashtags — body rewrite; swap old/new for undo)
- internal/audit/audit.go (ActionTagBulk at :68 — add ActionTagUndo)
- internal/server/router.go (admin tag subgroup)
</read_first>

<action>
**1. Add `ActionTagUndo` audit constant** in `internal/audit/audit.go`:
```go
ActionTagUndo = "tag_undo"
```

**2. Add `handleTagUndo`** in `handlers_tags_admin.go` — `POST /admin/tags/undo`:
- Read the last journal row: `SELECT id, op_type, op_detail, pages FROM tag_op_journal ORDER BY id DESC LIMIT 1`.
- If no row → 400 ("No tag operation to undo").
- Parse `op_type`, `op_detail`, `pages` (JSON).
- For each page in the record, build an inverse `TagApplyItem`:
  - Read the page FRESH via `h.pages.Get(ctx, page.PagePath)` — get current revision (TOCTOU-safe).
  - `Tags`: `page.PreOpTags` (restore the original frontmatter tags).
  - `BaseRevision`: the FRESH `pg.Revision` (NOT the stored one — the page may have moved).
  - `SkipFrontmatter`: false (we're restoring frontmatter).
  - `BodyOld`/`BodyNew`: based on op_type:
    - rename: swap → `BodyOld = detail.new_name, BodyNew = detail.old_name`.
    - merge: swap → `BodyOld = detail.target, BodyNew = detail.source`.
    - delete: `BodyOld = "", BodyNew = ""` (body #tags removed by delete are NOT invertible — stated limitation; the undo restores frontmatter tags only; body #tags self-heal on the next edit).
    - bulk: `BodyOld = "", BodyNew = ""` (bulk is frontmatter-only; undo is frontmatter-only).
- Call `h.pages.ApplyTagsBatch(ctx, inverseItems, actor)`.
- Audit: `h.audit.Record(ctx, audit.Event{Action: audit.ActionTagUndo, Actor: actor, Detail: fmt.Sprintf("undo %s", opType)})`.
- Journal the undo op itself: `h.journalOp(ctx, "undo", map[string]string{"undid": opType}, undoPreview)` — so undo-of-undo (redo) works.
- Return the `tagApplyResponse{Action: "undo", Results}` + a note field with the delete limitation if applicable.

**3. Register the route** in `router.go`:
```go
admin.Post("/admin/tags/undo", h.handleTagUndo)
```

**4. Write tests** in `handlers_tags_admin_test.go`:
- `TestUndoRBAC` — editor gets 403.
- `TestUndoNothing` — no journal rows → 400.
- `TestUndoRenameGolden` (TF-05 crit 4): rename `#ops`→`#devops` on a page with body `#ops`, then undo → page frontmatter + body `#tags` byte-match pre-rename (body restored via RewriteHashtags(body, "devops", "ops")).
- `TestUndoStale` — rename, then edit a page, then undo → the edited page is reported stale and skipped; the other page is undone.
- `TestUndoDelete` — delete a tag, then undo → frontmatter tags restored; note that body #tags removed by delete are NOT restored (stated limitation).
- `TestUndoBulk` — bulk add/remove, then undo → frontmatter tags restored.
- `TestUndoOfUndo` — rename, undo, undo again → the second undo redoes the rename (undo-of-undo works).
</action>

<acceptance_criteria>
- `grep 'ActionTagUndo = "tag_undo"' internal/audit/audit.go` returns a match
- `grep 'func (h \*authHandlers) handleTagUndo' internal/server/handlers_tags_admin.go` returns a match
- `grep 'admin.Post("/admin/tags/undo"' internal/server/router.go` returns a match
- `go test ./internal/server/ -run TestUndo -count=1 -v` — all 7 tests pass
- `go build ./...` exits 0
</acceptance_criteria>

### Task 3: Frontend — undo button (Wave 2)

<read_first>
- web/src/api/client.ts (tag API functions)
- web/src/components/admin/TagManagement.tsx (the tag table header)
</read_first>

<action>
**1. Add `undoLastTagOp` API function** in `client.ts`:
```typescript
export async function undoLastTagOp(): Promise<TagApplyResponse> {
	return mutate<TagApplyResponse>("/api/v1/admin/tags/undo", {});
}
```

**2. Add "Undo last tag operation" button** to `TagManagement.tsx`:
- A button near the tag table header (beside "Bulk add/remove").
- On click: calls `undoLastTagOp()`, shows the results (per-page status: applied/stale/notfound), invalidates `TAGS_KEY`.
- Error handling: if 400 ("nothing to undo"), show a subtle "Nothing to undo" message.
- The delete limitation note: if the undo response includes a note about body #tags, display it.

**3. Add CSS** for the undo button if needed (mirror existing button styles).
</action>

<acceptance_criteria>
- `grep 'export async function undoLastTagOp' web/src/api/client.ts` returns a match
- `grep 'undo\|Undo' 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):** Undo the last tag-admin op via inverse ApplyTagsBatch; byte-stable, 1 commit, per-page 409-guarded, forward-commit. The golden test proves rename undo restores frontmatter + body #tags. The stale test proves a concurrent edit is skipped. The delete limitation is stated.
- **Forward-commit:** verified by the undo handler calling `ApplyTagsBatch` (a forward write), never `git revert`.
- **409-guard:** verified by the stale test — a page edited since the op is skipped.
- **Delete limitation:** the undo restores frontmatter tags but NOT body #tags removed by a delete (the span is gone; self-heals on next edit). Stated in the response + SUMMARY.
- **Security:** admin-only RBAC + CSRF; the undo is a write op (audited). The op-journal is operational data in SQLite (appropriate; not content).