---
phase: 08-derived-link-tag-store-maintenance
plan: 03
type: execute
wave: 3
depends_on: ["08-02"]
files_modified:
  - internal/audit/audit.go
  - internal/server/handlers_search.go
  - internal/server/handlers_auth.go
  - internal/server/router.go
  - internal/server/handlers_graph_test.go
  - cmd/okf-workspace/main.go
  - web/src/api/client.ts
  - web/src/routes/Admin.tsx
  - web/src/api/client.test.ts
autonomous: true
requirements: [LINK-03]
must_haves:
  truths:
    - "An admin can POST /api/v1/admin/graph/reindex and the request returns 202 Accepted after enqueueing a graph rebuild fire-and-forget on the single worker (the request never blocks on the rebuild)"
    - "A non-admin (editor/reader) POSTing the graph reindex route is rejected with 403 — RBAC is read from the session role via the admin subgroup, never client input"
    - "The graph reindex is an audited admin action (ActionGraphReindex) recorded non-fatally, mirroring the search reindex audit row"
    - "An admin sees a 'Rebuild graph index' button in the Admin route's maintenance section with the same notice/error states as the existing 'Rebuild search index' button"
    - "No user-reachable copy (button label, error body, notice) names Bleve, Git, the index internals, or 'Reindex' — the affordance speaks 'Rebuild graph index' / 'Rebuild link index' only (hidden-Git rule)"
  artifacts:
    - path: "internal/server/handlers_search.go"
      provides: "handleGraphReindex (admin graph rebuild handler) + graphEnqueuer interface, cloning handleReindex/searchEnqueuer"
      contains: "handleGraphReindex"
    - path: "internal/audit/audit.go"
      provides: "ActionGraphReindex audit action constant"
      contains: "ActionGraphReindex"
    - path: "internal/server/router.go"
      provides: "POST /admin/graph/reindex route in the admin subgroup beside /admin/search/reindex; GraphJobs dep field"
      contains: "/admin/graph/reindex"
    - path: "web/src/routes/Admin.tsx"
      provides: "Rebuild graph index button + reindexGraph mutation with notice/error state"
      contains: "reindexGraph"
    - path: "web/src/api/client.ts"
      provides: "reindexGraph() api fn POSTing the admin graph reindex route"
      contains: "reindexGraph"
  key_links:
    - from: "internal/server/router.go"
      to: "internal/server/handlers_search.go"
      via: "admin.Post(\"/admin/graph/reindex\", h.handleGraphReindex) in the RequireRole(admin) subgroup"
      pattern: "handleGraphReindex"
    - from: "internal/server/handlers_search.go"
      to: "internal/graph/graphjob.go"
      via: "h.graphJobs.Enqueue(ctx, graph.KindGraph, graph.RebuildPayload()) fire-and-forget"
      pattern: "graph\\.RebuildPayload"
    - from: "cmd/okf-workspace/main.go"
      to: "internal/server/router.go"
      via: "server.Deps{ GraphJobs: worker } passing the same single worker SearchJobs uses"
      pattern: "GraphJobs"
    - from: "web/src/routes/Admin.tsx"
      to: "web/src/api/client.ts"
      via: "reindexGraph() called from a useMutation cloning reindexMut"
      pattern: "reindexGraph"
---

<objective>
Deliver the admin "rebuild" affordance for the derived link/graph store (LINK-03): a POST `/api/v1/admin/graph/reindex` endpoint that enqueues `graph.RebuildPayload()` on the single worker fire-and-forget (mirroring `handleReindex`), an `ActionGraphReindex` audit record, the route wired into the admin subgroup with the SAME RBAC + nosurf CSRF as the search reindex, and an `Admin.tsx` "Rebuild graph index" button with notice/error states cloning the search one.

Purpose: LINK-03 — "Admin can rebuild the link/graph index from files via an affordance consistent with the existing 'Rebuild search index' button." The graph store (`page_links`/`page_tags`, 08-01) is a derived cache; an admin needs a one-click way to force a from-files rebuild if it ever looks stale, exactly like the search rebuild. This is a thin, well-understood clone of an existing shipped feature — REUSE its shape (handler, interface, route group, audit action, button), do not reinvent it.

Output: an `ActionGraphReindex` constant; `handleGraphReindex` + `graphEnqueuer` in the server package; the `GraphJobs` dep + admin route + `authHandlers.graphJobs` field; `main.go` passing the same worker as `GraphJobs`; the `reindexGraph` api fn + the Admin.tsx button; and a handler test proving 202-for-admin / 403-for-non-admin with no hidden-Git vocabulary. This plan adds NO new data layer — it reuses the graph Store + worker wired in 08-02; it does NOT construct a second store.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-derived-link-tag-store-maintenance/08-CONTEXT.md
@.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md
@.planning/phases/08-derived-link-tag-store-maintenance/08-02-SUMMARY.md

# The EXACT feature to clone (read these — the new code is a structural copy):
@internal/server/handlers_search.go
@internal/server/handlers_auth.go
@internal/server/router.go
@internal/server/handlers_search_test.go
@internal/server/handlers_users_test.go
@internal/audit/audit.go
@web/src/routes/Admin.tsx
@web/src/api/client.ts
@cmd/okf-workspace/main.go
</context>

<artifacts_this_phase_produces>
New symbols introduced by THIS plan (08-03). Names to match exactly:
- `audit.ActionGraphReindex = "graph_reindex"` — the audit action constant (beside `ActionSearchReindex`).
- Server: `graphEnqueuer` interface (identical shape to `searchEnqueuer`: `Enqueue(ctx, kind, payload string) error`); `(*authHandlers).handleGraphReindex` handler; `authHandlers.graphJobs graphEnqueuer` field; `server.Deps.GraphJobs graphEnqueuer` field.
- Route: `POST /api/v1/admin/graph/reindex` registered in the admin subgroup.
- main.go: `GraphJobs: worker` added to the `server.Deps{}` literal (the same `worker` already passed as `SearchJobs`).
- Frontend: `reindexGraph(): Promise<void>` in `web/src/api/client.ts`; the "Rebuild graph index" button + `reindexGraphMut`/`graphReindexNotice`/`graphReindexError` state in `web/src/routes/Admin.tsx`.
- Test: `internal/server/handlers_graph_test.go` (admin-only graph reindex test); a `reindexGraph` assertion in `web/src/api/client.test.ts`.
</artifacts_this_phase_produces>

<tasks>

<task type="auto">
  <name>Task 1: ActionGraphReindex + handleGraphReindex handler/route + GraphJobs wiring</name>
  <files>internal/audit/audit.go, internal/server/handlers_search.go, internal/server/handlers_auth.go, internal/server/router.go, cmd/okf-workspace/main.go</files>
  <read_first>
    @internal/server/handlers_search.go (handleReindex + searchEnqueuer + searchUnavailable: the EXACT handler/interface to clone — copy its 202/audit/fire-and-forget shape)
    @internal/server/handlers_auth.go (authHandlers struct lines 42-70: how searchJobs is held as a field; add graphJobs the same way)
    @internal/server/router.go (Deps struct lines 49-51 SearchJobs field; handler construction lines 75-86; admin subgroup lines 222-232 where admin.Post("/admin/search/reindex", ...) is registered)
    @internal/audit/audit.go (lines 28-53: action const block; add ActionGraphReindex beside ActionSearchReindex)
    @cmd/okf-workspace/main.go (lines 342-352: the server.Deps{} literal passing SearchJobs: worker — add GraphJobs: worker the same way; the graph store/worker are already wired here by 08-02)
    @.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md (graph.KindGraph + graph.RebuildPayload exact names)
    @.planning/phases/08-derived-link-tag-store-maintenance/08-02-SUMMARY.md (where the graph Store + worker are constructed in main.go — reuse that worker, do NOT construct a second store)
  </read_first>
  <action>
Add `ActionGraphReindex = "graph_reindex"` to the action const block in `internal/audit/audit.go`, on the line immediately after `ActionSearchReindex` (same group, same string-snake-case convention).

In `internal/server/handlers_search.go`, add a `graphEnqueuer` interface identical in shape to `searchEnqueuer` (`Enqueue(ctx context.Context, kind, payload string) error`) — a separate named interface so the graph handler does not couple to the search enqueuer field. Then add `handleGraphReindex` as a structural clone of `handleReindex`:
- Guard on `h.graphJobs == nil` (return 500 with a generic graph-unavailable copy — define a `graphUnavailable` const e.g. "The link index is unavailable. Try again in a moment." that names NO Git/Bleve/index-internal term; do NOT reuse `searchUnavailable` since the copy should reference the link/graph affordance, not search).
- `h.graphJobs.Enqueue(r.Context(), graph.KindGraph, graph.RebuildPayload())`; on error log server-side only (slog) and return 500 with the generic copy.
- Record the audit event with `Action: audit.ActionGraphReindex`, actor from `auth.CurrentUser`, `Detail: "rebuild graph index"` (hidden-Git label, never "reindex Bleve"/"git"), `Source: "web-ui"`.
- Return `writeJSON(w, http.StatusAccepted, map[string]bool{"ok": true})`.
- Add the `internal/graph` import to handlers_search.go.

In `internal/server/handlers_auth.go`, add a `graphJobs graphEnqueuer` field to the `authHandlers` struct (with a doc comment mirroring the `searchJobs` one: optional; when nil the graph reindex returns a 500).

In `internal/server/router.go`:
- Add a `GraphJobs graphEnqueuer` field to `Deps` (doc comment mirroring `SearchJobs`).
- Set `graphJobs: deps.GraphJobs` in the `&authHandlers{...}` literal.
- Register `admin.Post("/admin/graph/reindex", h.handleGraphReindex)` in the admin subgroup, immediately after the existing `admin.Post("/admin/search/reindex", h.handleReindex)` line — so it inherits the SAME `RequireRole(auth.RoleAdmin)` gate and the global nosurf CSRF on mutating methods (the gating is read from the session role, never client input).

In `cmd/okf-workspace/main.go`, add `GraphJobs: worker,` to the `server.Deps{}` literal beside the existing `SearchJobs: worker,` line — passing the SAME single worker (the graph KindGraph handler is already registered on it by 08-02). Do NOT construct a new worker or a second graph store.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go build ./... && go vet ./internal/server/... ./cmd/... && go test ./internal/audit/ -count=1</automated>
  </verify>
  <acceptance_criteria>
    - `audit.ActionGraphReindex` exists with value "graph_reindex" beside ActionSearchReindex.
    - `handleGraphReindex` enqueues `graph.KindGraph` + `graph.RebuildPayload()` fire-and-forget and returns 202 on success; a nil graphJobs returns 500 with the generic graph-unavailable copy.
    - The generic copy and the audit Detail contain NO Bleve/Git/HEAD/commit/"reindex" vocabulary (hidden-Git rule); details go to slog only.
    - `Deps.GraphJobs` + `authHandlers.graphJobs` exist; `router.go` wires graphJobs from deps and registers POST /admin/graph/reindex in the admin (RequireRole admin) subgroup.
    - `main.go` passes `GraphJobs: worker` (the same worker as SearchJobs); `go build ./...` stays CGO-free with no new dependency.
  </acceptance_criteria>
  <done>The admin graph-reindex endpoint exists, enqueues a from-files rebuild fire-and-forget on the single worker, is audited, and is gated by the admin RBAC + CSRF identically to the search reindex.</done>
</task>

<task type="auto">
  <name>Task 2: Admin-only graph reindex handler test (202 admin / 403 non-admin, no hidden-Git vocab)</name>
  <files>internal/server/handlers_graph_test.go</files>
  <read_first>
    @internal/server/handlers_search_test.go (newSearchServer fixture + TestReindexAdminOnly + assertNoGitVocab — clone the fixture wiring and the admin-only test verbatim for the graph route; note the worker.Register call and the server.Deps fields)
    @internal/server/handlers_users_test.go (lines 72-126: shared loginAs / doMutate / loginAsAdmin helpers reused across server_test — reuse them, do not redefine)
    @.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md (graph Store constructor + graph.KindGraph + graph.GraphHandler exact names to register on the test worker)
  </read_first>
  <action>
Create `internal/server/handlers_graph_test.go` in `package server_test`. Stand up a server fixture mirroring `newSearchServer`: a temp store (migrated, so the 0009 graph tables exist), a real content repo + gitstore, the graph Store constructed via its 08-01 constructor (`SetRepo`/`SetDB`-or-`SetGit` per the SUMMARY), a real `jobs.Worker` with `graph.GraphHandler` registered for `graph.KindGraph`, and `server.New(server.Deps{ ..., GraphJobs: w })`. Reuse the shared `loginAs`/`doMutate`/`loginAsAdmin` helpers and the `assertNoGitVocab` helper already in the package (do NOT redefine them — they live in the same `server_test` package).

Add `TestGraphReindexAdminOnly` cloning `TestReindexAdminOnly`:
- An editor POSTing `/api/v1/admin/graph/reindex` gets 403 (RBAC from the session role).
- The bootstrap admin POSTing it gets 202 Accepted.
- Run `assertNoGitVocab` on the admin response body to prove no Bleve/Git/index-internal vocabulary leaks.

Keep the fixture minimal: it only needs the graph store + worker + GraphJobs (no search.Index required for this test). If the fixture cannot construct the graph store without a search index in scope, follow the same constructor the 08-02 main.go wiring uses (recorded in 08-02-SUMMARY).
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/server/ -run 'TestGraphReindex' -count=1</automated>
  </verify>
  <acceptance_criteria>
    - `TestGraphReindexAdminOnly` passes: editor → 403, admin → 202 on POST /api/v1/admin/graph/reindex.
    - The admin 202 response body passes `assertNoGitVocab` (no bleve/index/head/commit/git/repo terms).
    - The test registers `graph.GraphHandler` for `graph.KindGraph` on the test worker and passes that worker as `GraphJobs` (the enqueue path is exercised end to end, not stubbed).
    - The test reuses the shared server_test helpers (no duplicate loginAs/doMutate/assertNoGitVocab definitions → package still compiles).
  </acceptance_criteria>
  <done>The admin graph-reindex endpoint is proven to be admin-only (202/403) and hidden-Git-clean by an automated server test exercising the real enqueue path.</done>
</task>

<task type="auto">
  <name>Task 3: reindexGraph api fn + "Rebuild graph index" Admin.tsx button</name>
  <files>web/src/api/client.ts, web/src/routes/Admin.tsx, web/src/api/client.test.ts</files>
  <read_first>
    @web/src/api/client.ts (lines 1019-1025 reindexSearch — clone it exactly, changing only the route to /api/v1/admin/graph/reindex and the doc comment to the graph affordance)
    @web/src/routes/Admin.tsx (lines 5-17 import block; lines 132-149 reindex state + reindexMut; lines 219-245 the Search maintenance <section> with the button/notice/error — clone the import, the mutation, and the section for graph)
    @web/src/api/client.test.ts (how an api fn that calls mutate() is asserted — clone the reindexSearch test for reindexGraph if one exists, else add a minimal one asserting it POSTs the graph route)
  </read_first>
  <action>
In `web/src/api/client.ts`, add `reindexGraph(): Promise<void>` as an exact clone of `reindexSearch`, changing only the route to `/api/v1/admin/graph/reindex` and the doc comment to describe the link/graph rebuild (same hidden-Git rule: no Git/index/Bleve vocabulary in the comment or any thrown error).

In `web/src/routes/Admin.tsx`:
- Import `reindexGraph` from `../api/client` (beside `reindexSearch` in the existing import).
- Add `graphReindexNotice`/`graphReindexError` state and a `reindexGraphMut` `useMutation` cloning `reindexMut` (onSuccess sets notice "Graph index rebuild started." / clears error; onError sets error / clears notice).
- Add a "Graph" maintenance `<section className="admin-section">` cloning the existing "Search" section (heading, muted helper copy explaining it runs in the background and won't interrupt anyone, the button, the notice span, the error div). The button label is `reindexGraphMut.isPending ? "Starting…" : "Rebuild graph index"` and `disabled={reindexGraphMut.isPending}`. Place this section immediately after the existing Search section. Reuse the SAME CSS classes (`admin-section`, `admin-section-heading`, `admin-muted`, `admin-section-row`, `btn btn-secondary`, `field-error`) — no new CSS needed.

The button label is "Rebuild graph index" (or "Rebuild link index") — never "Reindex", "Bleve", or any Git/index-internal term. The helper and error copy carry no Git vocabulary either.

In `web/src/api/client.test.ts`, add an assertion that `reindexGraph()` issues a POST to `/api/v1/admin/graph/reindex` (clone the reindexSearch test if present; otherwise add a minimal mocked-fetch test mirroring the existing api-fn tests in that file).
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace/web && npx tsc -b && npx vitest run src/api/client.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `reindexGraph()` exists in client.ts and POSTs `/api/v1/admin/graph/reindex` via the shared `mutate` helper (CSRF-protected like reindexSearch).
    - Admin.tsx renders a "Rebuild graph index" button in a maintenance section with notice/error states cloned from the search button; `tsc -b` typechecks clean.
    - The button label, helper copy, and error rendering contain no "Reindex"/"Bleve"/Git/index-internal vocabulary (hidden-Git rule).
    - `client.test.ts` asserts reindexGraph hits the graph route; `vitest run` passes.
  </acceptance_criteria>
  <done>An admin sees a "Rebuild graph index" button consistent with the "Rebuild search index" button, wired to the new endpoint with notice/error feedback, and the api fn is test-covered.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| HTTP admin client → /admin/graph/reindex → worker enqueue | A privileged operational action; authorization MUST come from the session role (RequireRole admin), never the request body, and the mutating POST MUST carry CSRF |
| graph reindex handler → user-facing response copy | Error/notice copy crosses to the client; it must never leak Bleve/Git/index internals (hidden-Git rule) |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-08 | Elevation of privilege | POST /admin/graph/reindex | mitigate | Registered inside the `RequireRole(auth.RoleAdmin)` admin subgroup so RBAC is read from the session role (never client input); an editor/reader gets 403 — gated by an explicit handler test (editor → 403, admin → 202) |
| T-08-09 | Spoofing / CSRF | mutating graph reindex POST | mitigate | The POST inherits the global nosurf double-submit CSRF on mutating methods (same as /admin/search/reindex); the SPA echoes the X-CSRF-Token header via the shared `mutate` helper |
| T-08-10 | Denial of Service | graph reindex enqueue | mitigate | Enqueue is fire-and-forget (worker.Enqueue, never EnqueueAndWait — CR-01); the request returns 202 immediately and never blocks on the rebuild; an enqueue error logs server-side and returns a generic 500 |
| T-08-11 | Information disclosure | response/audit copy | mitigate | The generic graph-unavailable copy + audit Detail use the hidden-Git label only; error details go to slog server-side; an automated `assertNoGitVocab` check fails the build if Bleve/Git/index vocabulary leaks |
| T-08-12 | Repudiation | admin rebuild action | mitigate | The action is recorded with `audit.ActionGraphReindex` + the acting admin's id (SEC-05), non-fatally (audit.Record never breaks the request path), mirroring the search reindex audit |
| T-08-SC | Tampering | npm/pip/cargo installs | mitigate | This plan adds NO new dependency (it reuses existing audit/server/graph/jobs + the existing react-query mutate path); no package install task exists, so no legitimacy checkpoint is required |
</threat_model>

<verification>
- `go build ./...` succeeds; binary stays CGO-free (no new dependency).
- `go vet ./internal/server/... ./cmd/...` clean.
- `go test ./internal/audit/ -count=1` passes (ActionGraphReindex compiles into the action set).
- `go test ./internal/server/ -run 'TestGraphReindex' -count=1` passes (admin → 202, editor → 403, no hidden-Git vocab).
- `cd web && npx tsc -b && npx vitest run src/api/client.test.ts` passes (reindexGraph typechecks + hits the graph route).
- Manual reasoning check: the graph reindex route sits inside the same admin (RequireRole admin) subgroup as the search reindex, and main.go passes the SAME worker as GraphJobs (no second store constructed).
</verification>

<success_criteria>
- An admin can POST /api/v1/admin/graph/reindex and get 202 after a fire-and-forget rebuild enqueue; a non-admin gets 403.
- The action is audited as ActionGraphReindex and emits no hidden-Git vocabulary in any user-reachable copy.
- The Admin route shows a "Rebuild graph index" button consistent with the "Rebuild search index" button, wired to the new endpoint with notice/error feedback.
- The endpoint reuses the graph Store + worker wired in 08-02 (no second store constructed) and adds no new dependency.
- LINK-03 is fully covered: an admin can rebuild the link/graph index from files via an affordance consistent with the existing search rebuild.
</success_criteria>

<output>
Create `.planning/phases/08-derived-link-tag-store-maintenance/08-03-SUMMARY.md` when done. Record the new endpoint path (POST /api/v1/admin/graph/reindex), the handler func name (handleGraphReindex), the audit action (ActionGraphReindex), and the frontend api fn + button (reindexGraph / "Rebuild graph index"), and note that LINK-03 is fully covered.
</output>
