---
phase: 09-graph-endpoints-backlinks-panel
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - internal/graph/query.go
  - internal/graph/query_test.go
  - internal/server/handlers_graph.go
  - internal/server/handlers_graph_test.go
  - internal/server/router.go
  - internal/server/handlers_auth.go
  - cmd/okf-workspace/main.go
autonomous: true
requirements: [LINK-02]

must_haves:
  truths:
    - "GET /api/v1/graph returns a lean typed-edge payload: all page nodes (type page) + tag nodes (type tag), page->page link edges (type link) and page->tag membership edges (type tag) — ids + labels only, NO page bodies"
    - "GET /api/v1/graph/local?path=&depth= returns the named page plus its direct neighbors (depth default 1) as the same lean node/edge shape"
    - "GET /api/v1/graph/backlinks?path= returns the list of pages that link TO the given page (reverse page_links query), each entry carrying its path + resolved title"
    - "Shared-tag membership edges respect a popular-tag cap: a tag on more than a named-constant share of pages is excluded so a ubiquitous tag node can never become a hairball hub"
    - "All three endpoints are authed (any logged-in user) — mounted in the same authed read group as /tree and /search, NOT editor/admin gated"
    - "The graph payload is built ONLY from the Phase-8 page_links/page_tags cache tables — no .md file reads in the request path"
  artifacts:
    - path: "internal/graph/query.go"
      provides: "read-only Store query methods: GraphData (all nodes+edges, popular-tag capped), Neighborhood (page+neighbors at depth), Backlinks (reverse page_links + title)"
      contains: "func (s *Store) GraphData"
    - path: "internal/server/handlers_graph.go"
      provides: "handleGraph / handleGraphLocal / handleGraphBacklinks authed read handlers (lean JSON, generic error copy)"
      exports: ["handleGraph", "handleGraphLocal", "handleGraphBacklinks"]
    - path: "internal/graph/query_test.go"
      provides: "tests for lean shape (no bodies), bipartite page/tag nodes, popular-tag cap, neighborhood depth, backlinks reverse query"
      contains: "popular"
  key_links:
    - from: "internal/server/handlers_graph.go"
      to: "internal/graph/query.go"
      via: "handlers call h.graph.GraphData / Neighborhood / Backlinks"
      pattern: "h\\.graph\\.(GraphData|Neighborhood|Backlinks)"
    - from: "internal/server/router.go"
      to: "internal/server/handlers_graph.go"
      via: "authed.Get(\"/graph\", ...) mounts the three reads in the authed group"
      pattern: "authed\\.Get\\(\"/graph"
    - from: "cmd/okf-workspace/main.go"
      to: "internal/graph/query.go"
      via: "server.Deps.Graph = graphStore wires the read store into the server"
      pattern: "Graph:\\s*graphStore"
---

<objective>
Expose the Phase-8 derived link/tag adjacency over HTTP as typed-edge graph payloads plus a backlinks reverse query, all in the authed read group. This is the read API that Phase 10's graph UI consumes and that this phase's backlinks panel (plan 09-02) consumes.

Purpose: LINK-02 needs a backlinks read; the Phase-9 success criteria additionally require the global + local graph endpoints (they feed Phase 10). All reads are built ONLY from the existing `page_links`/`page_tags` cache tables — no new maintenance, no file reads in the request path. The bipartite page<->tag node model (locked in 09-CONTEXT.md) plus a popular-tag cap keeps the payload from exploding.

Output: a read-only `internal/graph` query layer (`query.go`), three authed handlers (`handlers_graph.go`), route mounts in `router.go`, the `graph` dependency wired through `Deps`/`authHandlers`/`main.go`, and tests proving the lean shape, the bipartite model, and the popular-tag cap.
</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/09-graph-endpoints-backlinks-panel/09-CONTEXT.md
@.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md
</context>

<artifacts_this_phase_produces>
New Store read methods (plan 09-01):
- `(*Store).GraphData(ctx) (GraphData, error)` — all page+tag nodes and all typed edges, popular-tag capped.
- `(*Store).Neighborhood(ctx, path string, depth int) (GraphData, error)` — the page + its direct neighbors at depth (default 1).
- `(*Store).Backlinks(ctx, path string) ([]BacklinkEntry, error)` — reverse page_links query with resolved titles.

New endpoint paths (authed read group):
- `GET /api/v1/graph` — global graph payload (lean: ids + labels + typed edges, no bodies).
- `GET /api/v1/graph/local?path=<page>&depth=<n>` — neighborhood (depth default 1).
- `GET /api/v1/graph/backlinks?path=<page>` — backlinks list for the page.

New handler funcs: `handleGraph`, `handleGraphLocal`, `handleGraphBacklinks` (internal/server/handlers_graph.go).
Wiring: `Deps.Graph` field, `authHandlers.graph` field, `main.go` `Graph: graphStore` in `server.New(server.Deps{...})`.
</artifacts_this_phase_produces>

<design_decisions>
These resolve the "Claude's discretion" items in 09-CONTEXT.md; executor MUST follow them.

- Backlinks endpoint is a QUERY-PARAM read (`/api/v1/graph/backlinks?path=`) mounted beside `/graph` and `/graph/local`, NOT a `/pages/{path}/backlinks` path route. Reason (per LINK-02, router.go comments): the `/pages/*` catch-all already hosts a wildcard-suffix dispatcher and chi cannot host a sibling wildcard route there. Keeping all three graph reads on query params avoids that conflict and keeps them co-located in the authed group.
- JSON schema (lean): node = `{"id": string, "label": string, "type": "page"|"tag"}`. Edge = `{"source": string, "target": string, "type": "link"|"tag"}`. The endpoint exposes DIRECTION (source->target) so backlinks are derivable client-side; a separate `backlink` edge type is NOT emitted in the global payload (direction carries it). `tag` edges are page(source)->tag(target) membership edges. NO node carries a body, frontmatter, or excerpt.
- Tag node id MUST be namespaced so it can never collide with a page path: prefix `tag:` (e.g. `tag:design`). Tag node label is the bare tag.
- Popular-tag cap: a NAMED constant `popularTagShare = 0.25` (a tag appearing on more than 25% of pages is excluded from BOTH tag nodes and tag edges) AND `popularTagMinPages = 8` (the cap only applies once the workspace has at least 8 pages, so a 3-page workspace is never over-pruned). Both are package constants in query.go with a comment explaining the hairball-prevention intent.
- Depth default 1; clamp to a max of 3 to keep local payloads small. depth<1 or missing => 1.
- Backlink title resolution: read the linking page's title cheaply. Reuse the pages.Service title lookup pattern (okf.Field title with filename fallback) by adding a lean title resolver — the handler is given a `titleFor(path) string` func (or the Store holds the repo it already has via SetRepo and resolves titles itself). Prefer resolving titles in the handler via the pages service to keep the Store free of body parsing; if that is awkward, resolve in the Store using its attached repo. Either way titles are best-effort (fallback to the path basename) and a title read error never fails the backlinks request.
</design_decisions>

<read_first>
Before writing code, read these to mirror exact patterns and symbols:

- internal/graph/store.go — Store type, db/repo/gs fields, OpenStore/SetRepo/SetGit, readMeta. The new query methods are methods on this SAME Store.
- internal/graph/graphjob.go — how rows are written (page_links src_path/dst_path; page_tags page_path/tag). Reverse query for backlinks = SELECT src_path FROM page_links WHERE dst_path=?.
- internal/graph/rebuild.go — trashPrefix/isTrashed (live-page filtering is already done at write time; the cache tables only hold live pages, so query methods do NOT re-filter trash).
- internal/store/migrations/0009_graph.sql — table columns + idx_page_links_dst (backlinks index) + idx_page_tags_tag (shared-tag/tag-node join).
- internal/server/handlers_search.go — the EXACT authed read handler pattern: nil-dependency guard returning a generic copy, slog.Error on failure with generic copy to client (hidden-Git rule: never name SQLite/Git/index), writeJSON(w, 200, results). The `searchUnavailable`/`graphUnavailable` generic-copy constants live here; reuse `graphUnavailable` or add a peer constant.
- internal/server/handlers_tree.go — handleTree shape (any-authed read, nil guard, writeJSON).
- internal/server/router.go lines 112-151 — the authed group where /tree and /search are mounted; mount /graph, /graph/local, /graph/backlinks there with authed.Get. Do NOT add them to the editor or admin subgroups.
- internal/server/handlers_auth.go lines 42-73 — the authHandlers struct; add a `graph *graph.Store` field (optional-dependency pattern, like search).
- internal/server/router.go lines 22-92 — the Deps struct + the `h := &authHandlers{...}` construction; add `Graph *graph.Store` to Deps and `graph: deps.Graph` to the struct literal.
- cmd/okf-workspace/main.go lines 246-249 (graphStore already constructed: OpenStore + SetRepo + SetGit) and lines 376-389 (server.New(server.Deps{...})). Add `Graph: graphStore,` to that Deps literal. graphStore already exists — REUSE it, do not construct a second store.
- internal/graph/harness_test.go — newHarness, writePage(path,title,tags,body), snapshotLinks/snapshotTags. The query tests reuse this harness: writePage a few pages, run the KindGraph upsert/rebuild to populate the cache tables, then assert query method output.
- internal/pages/tree.go lines 142-156 — pageTitle (okf.Field title + basename fallback) — the title-resolution recipe to mirror for backlink titles.
- web/src/api/client.ts is NOT touched here (frontend is plan 09-02).
</read_first>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Read-only graph query layer (GraphData, Neighborhood, Backlinks) with popular-tag cap</name>
  <files>internal/graph/query.go, internal/graph/query_test.go</files>
  <behavior>
    - GraphData over a 3-page workspace (a links b, b links c, all tagged design): returns page nodes a/b/c (type page, label = title), a tag node id=tag:design (type tag, label design), link edges a->b and b->c (type link), and tag membership edges a->tag:design, b->tag:design, c->tag:design (type tag). No node has a body field.
    - Popular-tag cap: with >= popularTagMinPages pages where a tag appears on > popularTagShare of them, that tag's node AND its tag edges are EXCLUDED from GraphData; a tag under the threshold is included. (Build a workspace of 10 pages, tag 9 with "common" and 2 with "rare"; assert no tag:common node/edges, assert tag:rare present.)
    - Cap does NOT apply below popularTagMinPages: a 3-page workspace where every page has "design" still emits the tag:design node (small workspace not over-pruned).
    - Neighborhood(path=b.md, depth=1) over a->b->c: returns b plus its direct neighbors a (inbound link) and c (outbound link) and b's tag node; does NOT include a node two hops away. depth=0 or negative clamps to 1; depth>3 clamps to 3.
    - Backlinks(path=b.md) over a->b and c->b: returns entries for a.md and c.md, each with the linking page's resolved title (fallback to basename); Backlinks(path with no inbound links) returns an empty (non-nil) slice.
    - Lean-shape guard test: marshal GraphData to JSON and assert the byte output contains "type":"page" and "type":"tag" and "type":"link" and does NOT contain "body" or "frontmatter".
  </behavior>
  <action>
Create internal/graph/query.go on the existing Store. Define exported result types: GraphNode {ID string json:"id"; Label string json:"label"; Type string json:"type"}, GraphEdge {Source string json:"source"; Target string json:"target"; Type string json:"type"}, GraphData {Nodes []GraphNode json:"nodes"; Edges []GraphEdge json:"edges"}, BacklinkEntry {Path string json:"path"; Title string json:"title"}. Initialize Nodes/Edges/result slices to non-nil empty slices so empty workspaces marshal as [] not null (match the search empty-result convention).

Define package constants with hairball-prevention comments: popularTagShare = 0.25 and popularTagMinPages = 8.

GraphData(ctx): (1) SELECT DISTINCT page set = union of every src_path/dst_path in page_links plus every page_path in page_tags; emit a page node per distinct page (Type "page", Label = title via the title resolver below, ID = page path). (2) SELECT src_path,dst_path FROM page_links => link edges (Type "link", Source=src, Target=dst). (3) Compute per-tag page counts (SELECT tag, COUNT(DISTINCT page_path) FROM page_tags GROUP BY tag) and total distinct page count; for each tag, skip it (no node, no edges) when totalPages >= popularTagMinPages AND tagPageCount > popularTagShare*totalPages. For each surviving tag emit one tag node (ID = "tag:"+tag, Type "tag", Label = tag) and one tag edge per (page_path) row (Type "tag", Source=page_path, Target="tag:"+tag). Build all of this from the cache tables ONLY — no repo.Tree()/repo.Read() of bodies (title reads are the only file touch and are gated by the resolver note below).

Neighborhood(ctx, path, depth): clamp depth into [1,3]. BFS from path over BOTH link directions (page_links src<->dst) up to depth hops, collecting the reachable page set; then build the SAME node/edge shape restricted to that page set (link edges where both endpoints are in the set; tag nodes/edges for pages in the set, still popular-tag capped). Always include the seed page node even if it has no edges.

Backlinks(ctx, path): SELECT src_path FROM page_links WHERE dst_path=? ORDER BY src_path; for each, resolve a title and append BacklinkEntry. Return a non-nil empty slice for no results.

Title resolver: add an unexported helper titleFor(path) string on the Store that reads via s.repo (already attached via SetRepo) using okf.Parse + okf.Field title with a basename fallback (mirror internal/pages/tree.go pageTitle). A read/parse error => basename fallback, never an error return. If s.repo is nil (no-repo harness), fall back to basename. This is the ONLY place the query layer touches files, and only for titles, never bodies.

Write internal/graph/query_test.go using newHarness/writePage. Populate the cache tables by calling the existing rebuild/upsert path (e.g. h.st.RebuildGraph(ctx) after writing pages, or upsertPage per page) — do NOT hand-insert rows, so the tests exercise the real schema. Cover every bullet in <behavior>. Use json.Marshal for the lean-shape guard.

Do NOT import internal/search (preserve the 08-01 import-independence invariant).
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace &amp;&amp; CGO_ENABLED=0 go build ./... &amp;&amp; go test ./internal/graph/ &amp;&amp; go list -deps ./internal/graph | grep -q internal/search &amp;&amp; echo "FAIL: search import leaked" &amp;&amp; exit 1 || echo "OK no search import"</automated>
  </verify>
  <acceptance_criteria>
    - internal/graph/query.go exists with exported GraphData, Neighborhood, Backlinks methods on *Store and GraphNode/GraphEdge/GraphData/BacklinkEntry types.
    - popularTagShare and popularTagMinPages are named package constants with hairball-prevention comments.
    - go test ./internal/graph/ passes, including the popular-tag cap test (excluded above threshold, included below min-pages) and the lean-shape JSON guard (no "body"/"frontmatter" in marshalled output).
    - go list -deps shows internal/graph still does NOT import internal/search.
    - CGO_ENABLED=0 go build ./... succeeds (single CGO-free binary preserved).
  </acceptance_criteria>
  <done>The Store has read-only GraphData/Neighborhood/Backlinks methods returning the lean bipartite typed-edge shape, the popular-tag cap is enforced by a named constant and proven by test, and the package still does not import internal/search.</done>
</task>

<task type="auto">
  <name>Task 2: Authed graph read handlers + routes + dependency wiring</name>
  <files>internal/server/handlers_graph.go, internal/server/handlers_graph_test.go, internal/server/router.go, internal/server/handlers_auth.go, cmd/okf-workspace/main.go</files>
  <action>
Add `graph *graph.Store` to the authHandlers struct in internal/server/handlers_auth.go (optional-dependency comment mirroring the `search` field). Add `Graph *graph.Store` to the Deps struct in internal/server/router.go (doc comment: optional; when nil the graph reads return a 500 with the generic copy) and set `graph: deps.Graph` in the `h := &authHandlers{...}` literal.

Create internal/server/handlers_graph.go. Reuse the existing generic-copy constant graphUnavailable from handlers_search.go (it already names no Git/SQLite internal); if a more graph-read-specific copy is wanted, add a peer const there — but keep the hidden-Git rule (never name SQLite/Git/Bleve/index in client copy). Three handlers, each mirroring handleSearch's shape (nil-dependency guard => 500 generic; slog.Error with detail on failure; writeJSON 200 on success):
- handleGraph: data, err := h.graph.GraphData(r.Context()); writeJSON(w, 200, data).
- handleGraphLocal: read path := r.URL.Query().Get("path") (a query param, like search q — no cleanPathParam needed since it is not a path segment) and depth from r.URL.Query().Get("depth") parsed with strconv.Atoi (default 1 on missing/invalid); empty path => writeError 400 with a generic "A page is required." copy; else data, err := h.graph.Neighborhood(ctx, path, depth); writeJSON 200.
- handleGraphBacklinks: read path query param; empty path => 400 generic; else entries, err := h.graph.Backlinks(ctx, path); writeJSON 200 (entries is a non-nil slice => [] for none).

Mount the three routes in internal/server/router.go in the authed group, immediately after the `authed.Get("/search", h.handleSearch)` line (so they sit with the other any-authed reads): authed.Get("/graph", h.handleGraph); authed.Get("/graph/local", h.handleGraphLocal); authed.Get("/graph/backlinks", h.handleGraphBacklinks). Add a comment noting these are any-authed reads (NOT editor/admin) mirroring /tree + /search, and that backlinks is a query-param read to avoid the /pages/* sibling-wildcard conflict.

Wire the store in cmd/okf-workspace/main.go: add `Graph: graphStore,` to the existing server.New(server.Deps{...}) literal (graphStore is already constructed at ~line 246 — REUSE it).

Write internal/server/handlers_graph_test.go. Follow the existing server handler test pattern (construct authHandlers with a real graph Store over a migrated temp DB + repo, or the existing server test harness if one exists — grep handlers_search_test.go / handlers_tree_test.go for the harness). Assert: GET /graph returns 200 with a nodes/edges JSON body for a small populated workspace; GET /graph/local?path=&depth= returns the neighborhood; GET /graph/backlinks?path= returns the backlinks list; missing path on local/backlinks returns 400; a nil graph dependency returns 500 with the generic copy and the body contains NO "git"/"sqlite"/"bleve" tokens (hidden-Git assertion).
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace &amp;&amp; CGO_ENABLED=0 go build ./... &amp;&amp; go test ./internal/server/ ./internal/graph/ &amp;&amp; go vet ./internal/server/ ./internal/graph/</automated>
  </verify>
  <acceptance_criteria>
    - internal/server/handlers_graph.go defines handleGraph, handleGraphLocal, handleGraphBacklinks following the handleSearch nil-guard + generic-copy + writeJSON pattern.
    - router.go mounts authed.Get for /graph, /graph/local, /graph/backlinks in the authed group (verifiable: grep 'authed.Get("/graph' router.go returns 3 lines) and NONE appear in the editor or admin subgroups.
    - Deps.Graph and authHandlers.graph fields exist; main.go passes Graph: graphStore in server.New.
    - go test ./internal/server/ passes including the missing-path 400 case and the nil-dependency 500-with-generic-copy (no git/sqlite/bleve tokens in the client body).
    - CGO_ENABLED=0 go build ./... and go vet succeed.
  </acceptance_criteria>
  <done>The three graph reads are mounted in the authed read group, return lean JSON for any logged-in user, reject a missing path with 400, emit only generic hidden-Git-safe error copy on failure, and the graph Store is wired through Deps/authHandlers/main.go.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client -> graph read endpoints | An authenticated (any-role) client sends `path`/`depth` query params; untrusted input crosses here. |
| handler -> SQLite cache tables | Query methods read page_links/page_tags; SQL must be parameterized. |
| query layer -> .md files (titles only) | Title resolution reads files through the SEC-01 resolver (s.repo), never os.* and never bodies. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-09-01 | Information Disclosure | graph read endpoints leaking page bodies/frontmatter | mitigate | Lean payload: nodes carry id+label+type only; lean-shape JSON test asserts no "body"/"frontmatter"; titles are the only file read. |
| T-09-02 | Information Disclosure | error copy naming SQLite/Git/Bleve (hidden-Git rule) | mitigate | All handler errors return the generic graphUnavailable copy; details go to slog only; test asserts no git/sqlite/bleve tokens in the client body. |
| T-09-03 | Tampering / Injection | path/depth query params into SQL or path resolution | mitigate | All SQL is parameterized (? binds, never string concat); depth is strconv.Atoi-parsed and clamped to [1,3]; title reads go through the SEC-01 resolver (s.repo), never os.*. |
| T-09-04 | Denial of Service | hairball payload from a ubiquitous tag node | mitigate | popular-tag cap (popularTagShare/popularTagMinPages constants) excludes over-threshold tags; local endpoint depth-clamped to <=3; both proven by test. |
| T-09-05 | Elevation of Privilege | a reader reaching the graph reads | accept | Reads are intentionally any-authed (mirror /tree + /search per 09-CONTEXT.md); no write surface is exposed; no editor/admin data is returned. |
| T-09-SC | Tampering | npm/pip/cargo installs | mitigate | No new dependency is added this plan (backend reads existing tables); no install task, so no package-legitimacy checkpoint is required. |
</threat_model>

<verification>
- CGO_ENABLED=0 go build ./... succeeds (single CGO-free binary).
- go test ./internal/graph/ ./internal/server/ passes (query layer + handlers).
- go list -deps ./internal/graph does not contain internal/search.
- grep 'authed.Get("/graph' internal/server/router.go returns exactly 3 lines (all in the authed group).
- The lean-shape JSON test proves no page bodies leak into the payload; the popular-tag cap test proves a ubiquitous tag is excluded.
</verification>

<success_criteria>
- GET /api/v1/graph returns all page + tag nodes and typed (link/tag) edges in a lean, body-free JSON payload, built only from the cache tables.
- GET /api/v1/graph/local?path=&depth= returns the page's neighborhood (depth default 1, clamped <=3).
- GET /api/v1/graph/backlinks?path= returns the reverse-link list with resolved titles.
- All three are any-authed reads; the popular-tag cap is a named constant proven by test; no SQLite/Git internal leaks into client copy.
</success_criteria>

<output>
Create `.planning/phases/09-graph-endpoints-backlinks-panel/09-01-SUMMARY.md` when done, listing the new Store query methods, the three endpoint paths + handler funcs, and the Deps/authHandlers/main.go wiring (so plan 09-02 and Phase 10 know the exact exported names and the backlinks endpoint URL).
</output>
